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 <b3nw@duck.com>
This commit is contained in:
1 parent
ecc47782e3
commit
e93b0c352a
12 files changed
+958
-15
No files matched your search
@@ -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/
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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<sid>[^\n]*?) completed "
|
||||
r"\(exit_code=(?P<exit_code>[^)\n]*)\)\.\n"
|
||||
r"Command: (?P<cmd>[^\n]*)\n"
|
||||
r"Output:\n"
|
||||
)
|
||||
_WAKEUP_WATCH_MATCH_RE = re.compile(
|
||||
r"\A\[IMPORTANT: Background process (?P<sid>[^\n]*?) matched watch pattern "
|
||||
r"\"(?P<pattern>.*)\"\.\n"
|
||||
r"Command: (?P<cmd>[^\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:
|
||||
|
||||
+3
-2
@@ -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:
|
||||
|
||||
+5
-8
@@ -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)
|
||||
|
||||
@@ -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 <name>` 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 <name>` 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',
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+97
-3
@@ -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=`<span class="process-wakeup-chip watch" title="${esc(t('process_wakeup_matched'))}">${li('eye',11)}<code title="${esc(String(info.pattern||''))}">${esc(String(info.pattern||''))}</code></span>`;
|
||||
}else{
|
||||
const cls=exitOk?'ok':(exitKnown?'fail':'neutral');
|
||||
const icon=exitOk?li('check',11):(exitKnown?li('x',11):'');
|
||||
chip=`<span class="process-wakeup-chip ${cls}">${icon}<span>exit ${esc(exitStr||'?')}</span></span>`;
|
||||
}
|
||||
const cmdHtml=info.command?`<code class="process-wakeup-cmd" title="${esc(info.command)}">${esc(info.command)}</code>`:'';
|
||||
// Preserve output byte-for-byte for the <pre>; 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()?`<pre class="process-wakeup-text">${esc(outRaw)}</pre>`:'';
|
||||
const cmdRow=info.command?`<div class="process-wakeup-cmd-row"><code>${esc(info.command)}</code></div>`:'';
|
||||
// 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)?`<div class="process-wakeup-pattern-row"><span class="process-wakeup-detail-key">${esc(t('process_wakeup_matched'))}</span><code>${esc(String(info.pattern))}</code></div>`:'';
|
||||
return `<details class="process-wakeup-card"><summary class="process-wakeup-summary"><span class="process-wakeup-toggle">${li('chevron-right',12)}</span><span class="process-wakeup-label">${li('terminal',13)}<span>${esc(t('process_wakeup_label'))}</span></span>${cmdHtml}${chip}${extras.timeHtml||''}</summary><div class="process-wakeup-detail">${extras.filesHtml||''}${patternRow}${cmdRow}<div class="msg-body process-wakeup-body">${outHtml}</div>${extras.footHtml||''}</div></details>`;
|
||||
}
|
||||
|
||||
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=`<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>${esc(t('process_wakeup_label'))}</span></div>${filesHtml}<div class="msg-body process-wakeup-body">${processTextHtml}</div>${processFootHtml}</div>`;
|
||||
// #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:`<div class="msg-foot"><span class="msg-actions">${copyBtn}</span></div>`});
|
||||
}else{
|
||||
const processTextHtml=processText?`<pre class="process-wakeup-text">${esc(processText)}</pre>`:'';
|
||||
noticeInnerHtml=`<div class="process-wakeup-label">${li('terminal',13)}<span>${esc(t('process_wakeup_label'))}</span></div>${filesHtml}<div class="msg-body process-wakeup-body">${processTextHtml}</div>${processFootHtml}`;
|
||||
}
|
||||
const nextRowHtml=`<div class="${noticeClass}">${noticeInnerHtml}</div>`;
|
||||
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 <details> 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);
|
||||
|
||||
@@ -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 ``<details>`` 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<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 esc(s){
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function li(name, size){ return '<svg data-icon="' + name + '"></svg>'; }
|
||||
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 "<script>alert(1)</script>"\nOutput:\n<b>bold</b>]';
|
||||
// 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: '<span class="msg-time">14:32</span>', filesHtml: '', footHtml: '<div class="msg-foot"></div>'};
|
||||
|
||||
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 <pre>.
|
||||
result = _run_driver()
|
||||
assert result["wsInfoOutput"] == " indented line\n\n"
|
||||
ws_card = result["wsCard"]
|
||||
assert "<pre class=\"process-wakeup-text\"> indented line\n\n</pre>" 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('<details class="process-wakeup-card">')
|
||||
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 '<span class="msg-time">14:32</span>' 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 "<script>" not in html_card
|
||||
assert "<script>" in html_card
|
||||
assert "<b>bold</b>" not in html_card
|
||||
assert "<b>bold</b>" in html_card
|
||||
|
||||
|
||||
def test_render_branch_and_css_wire_the_card_variant():
|
||||
ui = UI_JS_PATH.read_text(encoding="utf-8")
|
||||
branch_start = ui.find("if(isProcessWakeup){")
|
||||
branch_end = ui.find("if(isUser){", branch_start)
|
||||
assert branch_start != -1 and branch_end != -1
|
||||
branch = ui[branch_start:branch_end]
|
||||
|
||||
assert "_processWakeupInfo(m, processText)" in branch
|
||||
assert "process-wakeup-notice-card" in branch
|
||||
assert "process-wakeup-fail" in branch
|
||||
# The raw-notice fallback must survive for unparseable bodies.
|
||||
assert "process-wakeup-text" in branch
|
||||
assert "t('process_wakeup_label')" in branch
|
||||
|
||||
assert ".process-wakeup-card{" in STYLE_CSS
|
||||
assert ".process-wakeup-chip.ok{" in STYLE_CSS
|
||||
assert ".process-wakeup-chip.fail{" in STYLE_CSS
|
||||
assert ".process-wakeup-notice.process-wakeup-fail{" in STYLE_CSS
|
||||
assert ".process-wakeup-detail pre.process-wakeup-text{max-height" in STYLE_CSS
|
||||
# #6350 finding 3: watch chip shrinks, summary wraps, mobile gets a 44px target.
|
||||
assert ".process-wakeup-chip.watch{flex:0 1 auto;min-width:0;}" in STYLE_CSS
|
||||
base_summary = re.search(r"\.process-wakeup-card>summary\{[^}]*\}", STYLE_CSS)
|
||||
assert base_summary and "flex-wrap:wrap" in base_summary.group(0)
|
||||
assert "@media(max-width:700px){.process-wakeup-card>summary{min-height:44px;}}" in STYLE_CSS
|
||||
assert ".process-wakeup-pattern-row" in STYLE_CSS
|
||||
@@ -0,0 +1,123 @@
|
||||
"""#6350 review finding 3 — process-wakeup card must not clip on narrow viewports.
|
||||
|
||||
Real-browser measurement (Playwright) of the collapsed summary row for a
|
||||
worst-case long watch pattern at 390px (mobile) and 820px (tablet):
|
||||
|
||||
* the summary must not overflow the card horizontally (no clipped chip); and
|
||||
* the disclosure target must be >= 44px tall under the mobile breakpoint.
|
||||
|
||||
The production ``_processWakeupCardHtml`` source and the real wakeup CSS block
|
||||
are injected into a minimal page, so the measurement exercises the shipped
|
||||
markup + styles, not a hand-built approximation. Skips cleanly where Playwright
|
||||
or a browser binary is unavailable (matching the repo's other browser tests).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
LONG_PATTERN = "ERROR.*(timeout|refused|reset|unavailable).*after [0-9]+ retries over [0-9]+ seconds"
|
||||
|
||||
|
||||
def _extract_func(name: str) -> str:
|
||||
start = UI_JS.find(f"function {name}")
|
||||
assert start != -1, f"{name} not found"
|
||||
brace = UI_JS.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(UI_JS)):
|
||||
if UI_JS[i] == "{":
|
||||
depth += 1
|
||||
elif UI_JS[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return UI_JS[start:i + 1]
|
||||
raise AssertionError(f"{name} body did not close")
|
||||
|
||||
|
||||
def _fixture_script() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"');}",
|
||||
"function li(name,size){return '<svg data-icon=\"'+name+'\" style=\"width:11px;height:11px\"></svg>';}",
|
||||
"function t(k){return k==='process_wakeup_label'?'Background wakeup':(k==='process_wakeup_matched'?'Watch pattern matched':k);}",
|
||||
_extract_func("_parseProcessWakeupBody"),
|
||||
_extract_func("_processWakeupInfo"),
|
||||
_extract_func("_processWakeupCardHtml"),
|
||||
"""
|
||||
window.__renderWakeupCard = (pattern) => {
|
||||
const body = '[IMPORTANT: Background process w1 matched watch pattern "' + pattern
|
||||
+ '".\\nCommand: tail -f /var/log/app.log\\nMatched output:\\nERROR connection timeout]';
|
||||
const info = _processWakeupInfo({}, body);
|
||||
const extras = {timeHtml: '<span class="msg-time">14:32</span>', filesHtml: '', footHtml: ''};
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'process-wakeup-notice process-wakeup-notice-card';
|
||||
wrap.innerHTML = _processWakeupCardHtml(info, body, extras);
|
||||
document.body.appendChild(wrap);
|
||||
const card = wrap.querySelector('details.process-wakeup-card');
|
||||
const summary = wrap.querySelector('summary.process-wakeup-summary');
|
||||
const r = summary.getBoundingClientRect();
|
||||
return {
|
||||
summaryScrollWidth: summary.scrollWidth,
|
||||
summaryClientWidth: summary.clientWidth,
|
||||
cardClientWidth: card.clientWidth,
|
||||
summaryHeight: Math.round(r.height),
|
||||
docScrollWidth: document.documentElement.scrollWidth,
|
||||
docClientWidth: document.documentElement.clientWidth,
|
||||
};
|
||||
};
|
||||
""",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _measure(viewport_width: int):
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except Exception: # pragma: no cover - dependency missing path
|
||||
pytest.skip("playwright is unavailable; run the wakeup-card responsive browser test")
|
||||
|
||||
playwright = sync_playwright().start()
|
||||
# Only a missing/unlaunchable browser binary should skip; everything after a
|
||||
# successful launch (page setup, the measurement itself) must fail loudly so
|
||||
# a real regression can't hide behind a skip.
|
||||
try:
|
||||
browser = playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - no browser binary in sandbox
|
||||
playwright.stop()
|
||||
pytest.skip(f"chromium unavailable for browser measurement: {exc}")
|
||||
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": viewport_width, "height": 844})
|
||||
page.set_content("<!doctype html><html><head></head><body></body></html>")
|
||||
page.add_style_tag(content=STYLE_CSS)
|
||||
page.add_script_tag(content=_fixture_script())
|
||||
result = page.evaluate("(pattern) => window.__renderWakeupCard(pattern)", LONG_PATTERN)
|
||||
finally:
|
||||
browser.close()
|
||||
playwright.stop()
|
||||
return result
|
||||
|
||||
|
||||
def test_mobile_390_no_horizontal_overflow_and_44px_target():
|
||||
m = _measure(390)
|
||||
# No clipped chip: the summary content fits within its own box and the card.
|
||||
assert m["summaryScrollWidth"] <= m["summaryClientWidth"] + 1, m
|
||||
assert m["summaryClientWidth"] <= m["cardClientWidth"] + 1, m
|
||||
# The page itself must not scroll horizontally.
|
||||
assert m["docScrollWidth"] <= m["docClientWidth"] + 1, m
|
||||
# Disclosure target must meet the 44px mobile minimum.
|
||||
assert m["summaryHeight"] >= 44, m
|
||||
|
||||
|
||||
def test_tablet_820_no_horizontal_overflow():
|
||||
m = _measure(820)
|
||||
assert m["summaryScrollWidth"] <= m["summaryClientWidth"] + 1, m
|
||||
assert m["docScrollWidth"] <= m["docClientWidth"] + 1, m
|
||||
@@ -0,0 +1,154 @@
|
||||
"""#6345 — display metadata for process-wakeup messages.
|
||||
|
||||
``wakeup_display_meta`` must be an exact inverse of the two structured
|
||||
``format_wakeup_prompt`` shapes (completion, watch_match) so the WebUI can
|
||||
render a collapsed summary card without re-showing the agent-facing
|
||||
``[IMPORTANT: …]`` scaffolding. Everything else must return None so the UI
|
||||
keeps its raw-notice fallback.
|
||||
"""
|
||||
|
||||
from api.background_process import format_wakeup_prompt
|
||||
from api.process_event_utils import attach_wakeup_display_meta, wakeup_display_meta
|
||||
|
||||
|
||||
def _completion_evt(**overrides):
|
||||
evt = {
|
||||
"type": "completion",
|
||||
"session_id": "proc_123",
|
||||
"command": "npm run build",
|
||||
"exit_code": 0,
|
||||
"output": "done\nall green",
|
||||
}
|
||||
evt.update(overrides)
|
||||
return evt
|
||||
|
||||
|
||||
def test_completion_round_trip():
|
||||
meta = wakeup_display_meta(format_wakeup_prompt(_completion_evt()))
|
||||
assert meta == {
|
||||
"type": "completion",
|
||||
"task_id": "proc_123",
|
||||
"command": "npm run build",
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_completion_missing_exit_code_round_trips_as_question_mark():
|
||||
evt = _completion_evt()
|
||||
del evt["exit_code"]
|
||||
meta = wakeup_display_meta(format_wakeup_prompt(evt))
|
||||
assert meta["exit_code"] == "?"
|
||||
|
||||
|
||||
def test_completion_nonzero_exit_and_truncated_output():
|
||||
evt = _completion_evt(exit_code=3, output="x" * 5000)
|
||||
meta = wakeup_display_meta(format_wakeup_prompt(evt))
|
||||
assert meta["exit_code"] == 3
|
||||
|
||||
|
||||
def test_completion_negative_exit_code_round_trips():
|
||||
# Signal-killed processes report negative returncodes (e.g. SIGKILL → -9).
|
||||
meta = wakeup_display_meta(format_wakeup_prompt(_completion_evt(exit_code=-9)))
|
||||
assert meta["exit_code"] == -9
|
||||
|
||||
|
||||
def test_completion_empty_sid_and_command():
|
||||
meta = wakeup_display_meta(format_wakeup_prompt(_completion_evt(session_id="", command="")))
|
||||
assert meta["type"] == "completion"
|
||||
assert meta["task_id"] == ""
|
||||
assert meta["command"] == ""
|
||||
|
||||
|
||||
def test_watch_match_header_only_no_suppression_derived():
|
||||
# The producer appends a suppression note to the body; the parser must NOT
|
||||
# lift it into metadata — it is free-form output tail (#6350 finding 2).
|
||||
evt = {
|
||||
"type": "watch_match",
|
||||
"session_id": "w1",
|
||||
"command": "tail -f app.log",
|
||||
"pattern": "ERROR.*timeout",
|
||||
"output": "ERROR line",
|
||||
"suppressed": 3,
|
||||
}
|
||||
meta = wakeup_display_meta(format_wakeup_prompt(evt))
|
||||
assert meta == {
|
||||
"type": "watch_match",
|
||||
"task_id": "w1",
|
||||
"command": "tail -f app.log",
|
||||
"pattern": "ERROR.*timeout",
|
||||
}
|
||||
assert "suppressed" not in meta
|
||||
|
||||
|
||||
def test_watch_match_output_that_looks_like_suppression_is_not_misparsed():
|
||||
# Adversarial: legitimate output ends with the exact suppression phrasing
|
||||
# while the event's real suppressed count is 0. The metadata must carry no
|
||||
# suppression field, and the body must still contain the output verbatim so
|
||||
# the UI renders it (the phrase is not stripped).
|
||||
evt = {
|
||||
"type": "watch_match",
|
||||
"session_id": "w2",
|
||||
"command": "tail",
|
||||
"pattern": "ERR",
|
||||
"output": "log line\n(3 earlier matches were suppressed by rate limit)",
|
||||
"suppressed": 0,
|
||||
}
|
||||
body = format_wakeup_prompt(evt)
|
||||
meta = wakeup_display_meta(body)
|
||||
assert meta == {
|
||||
"type": "watch_match",
|
||||
"task_id": "w2",
|
||||
"command": "tail",
|
||||
"pattern": "ERR",
|
||||
}
|
||||
assert "suppressed" not in meta
|
||||
assert "(3 earlier matches were suppressed by rate limit)" in body
|
||||
|
||||
|
||||
def test_watch_match_pattern_with_quotes_round_trips():
|
||||
evt = {
|
||||
"type": "watch_match",
|
||||
"session_id": "w2",
|
||||
"command": "tail",
|
||||
"pattern": 'has "quotes" inside',
|
||||
"output": "m",
|
||||
}
|
||||
meta = wakeup_display_meta(format_wakeup_prompt(evt))
|
||||
assert meta["pattern"] == 'has "quotes" inside'
|
||||
assert "suppressed" not in meta
|
||||
|
||||
|
||||
def test_free_text_and_unknown_bodies_return_none():
|
||||
assert wakeup_display_meta("[IMPORTANT: watcher disabled after errors]") is None
|
||||
assert wakeup_display_meta("hello") is None
|
||||
assert wakeup_display_meta("") is None
|
||||
assert wakeup_display_meta(None) is None
|
||||
|
||||
|
||||
def test_attach_stamps_only_process_wakeup_sources():
|
||||
body = format_wakeup_prompt(_completion_evt())
|
||||
msg = {"role": "user", "content": body, "_source": "process_wakeup"}
|
||||
attach_wakeup_display_meta(msg, "process_wakeup")
|
||||
assert msg["_wakeup_meta"]["task_id"] == "proc_123"
|
||||
|
||||
untouched = {"role": "user", "content": body, "_source": "telegram"}
|
||||
attach_wakeup_display_meta(untouched, "telegram")
|
||||
assert "_wakeup_meta" not in untouched
|
||||
|
||||
|
||||
def test_attach_is_idempotent_and_tolerates_junk():
|
||||
msg = {"role": "user", "content": "unparseable", "_source": "process_wakeup"}
|
||||
attach_wakeup_display_meta(msg, "process_wakeup")
|
||||
assert "_wakeup_meta" not in msg
|
||||
|
||||
stamped = {
|
||||
"role": "user",
|
||||
"content": format_wakeup_prompt(_completion_evt()),
|
||||
"_wakeup_meta": {"type": "completion", "task_id": "keep_me"},
|
||||
}
|
||||
attach_wakeup_display_meta(stamped, "process_wakeup")
|
||||
assert stamped["_wakeup_meta"]["task_id"] == "keep_me"
|
||||
|
||||
# Never raises on non-dict / content-less inputs.
|
||||
attach_wakeup_display_meta(None, "process_wakeup")
|
||||
attach_wakeup_display_meta({"role": "user", "content": ["parts"]}, "process_wakeup")
|
||||
@@ -0,0 +1,174 @@
|
||||
"""#6350 review follow-up — server-materialized recovery turns carry metadata.
|
||||
|
||||
The wakeup summary card prefers server-stamped ``_wakeup_meta`` (authoritative)
|
||||
over the client parser. The maintainer review flagged that two older recovery
|
||||
paths stamped ``_source`` but skipped the metadata helper, so a future
|
||||
prompt-format change would silently drop back to the raw notice only on those
|
||||
paths:
|
||||
|
||||
* ``api.models._append_recovered_pending_turn`` (restart / core-resync recovery)
|
||||
* the ``cancel_stream`` outer-finally pending-turn recovery
|
||||
|
||||
Both now route through ``stamp_message_source``. These tests assert each path
|
||||
emits ``_source == "process_wakeup"`` AND a populated ``_wakeup_meta`` for a
|
||||
real (parseable) wakeup body, and leaves ordinary ``webui`` turns unmarked.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import threading
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import api.config as config
|
||||
import api.models as models
|
||||
from api.background_process import format_wakeup_prompt
|
||||
from api.models import Session, _append_recovered_pending_turn
|
||||
from api.streaming import cancel_stream
|
||||
|
||||
|
||||
def _wakeup_body(exit_code=0):
|
||||
body = format_wakeup_prompt(
|
||||
{
|
||||
"type": "completion",
|
||||
"session_id": "proc_42",
|
||||
"command": "npm run build",
|
||||
"exit_code": exit_code,
|
||||
"output": "all green",
|
||||
}
|
||||
)
|
||||
assert body is not None
|
||||
return body
|
||||
|
||||
|
||||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_session_dir(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
models.SESSIONS.clear()
|
||||
yield
|
||||
models.SESSIONS.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_stream_state():
|
||||
for name in (
|
||||
"STREAMS",
|
||||
"CANCEL_FLAGS",
|
||||
"AGENT_INSTANCES",
|
||||
"STREAM_PARTIAL_TEXT",
|
||||
"SESSION_AGENT_LOCKS",
|
||||
):
|
||||
if hasattr(config, name):
|
||||
getattr(config, name).clear()
|
||||
yield
|
||||
for name in (
|
||||
"STREAMS",
|
||||
"CANCEL_FLAGS",
|
||||
"AGENT_INSTANCES",
|
||||
"STREAM_PARTIAL_TEXT",
|
||||
"SESSION_AGENT_LOCKS",
|
||||
):
|
||||
if hasattr(config, name):
|
||||
getattr(config, name).clear()
|
||||
|
||||
|
||||
# ── _append_recovered_pending_turn (restart / core-resync recovery) ──────────
|
||||
|
||||
def test_append_recovered_pending_turn_stamps_wakeup_meta():
|
||||
s = Session(
|
||||
session_id="recover-meta-1",
|
||||
pending_user_message=_wakeup_body(),
|
||||
pending_user_source="process_wakeup",
|
||||
)
|
||||
recovered = _append_recovered_pending_turn(s)
|
||||
assert recovered is not None
|
||||
assert recovered["_source"] == "process_wakeup"
|
||||
assert recovered["_wakeup_meta"] == {
|
||||
"type": "completion",
|
||||
"task_id": "proc_42",
|
||||
"command": "npm run build",
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_append_recovered_pending_turn_webui_gets_no_meta():
|
||||
s = Session(
|
||||
session_id="recover-meta-2",
|
||||
pending_user_message="ordinary message",
|
||||
pending_user_source="webui",
|
||||
)
|
||||
recovered = _append_recovered_pending_turn(s)
|
||||
assert recovered is not None
|
||||
assert "_source" not in recovered
|
||||
assert "_wakeup_meta" not in recovered
|
||||
|
||||
|
||||
# ── cancel_stream outer-finally pending-turn recovery ────────────────────────
|
||||
|
||||
def _wire_cancel_state(session_id, stream_id="stream-meta"):
|
||||
models.SESSIONS[session_id].active_stream_id = stream_id
|
||||
config.STREAMS[stream_id] = queue.Queue()
|
||||
config.CANCEL_FLAGS[stream_id] = threading.Event()
|
||||
agent = Mock()
|
||||
agent.session_id = session_id
|
||||
agent.interrupt = Mock()
|
||||
config.AGENT_INSTANCES[stream_id] = agent
|
||||
return stream_id
|
||||
|
||||
|
||||
def test_cancel_stream_recovery_stamps_wakeup_meta():
|
||||
stream_id = "stream-meta"
|
||||
s = Session(
|
||||
session_id="cancel-meta-1",
|
||||
title="t",
|
||||
messages=[{"role": "assistant", "content": "prior report"}],
|
||||
)
|
||||
s.pending_user_message = _wakeup_body(exit_code=1)
|
||||
s.pending_user_source = "process_wakeup"
|
||||
s.pending_attachments = []
|
||||
s.pending_started_at = None
|
||||
s.active_stream_id = stream_id
|
||||
s.save()
|
||||
models.SESSIONS[s.session_id] = s
|
||||
_wire_cancel_state(s.session_id, stream_id)
|
||||
|
||||
assert cancel_stream(stream_id) is True
|
||||
|
||||
recovered = next(
|
||||
m for m in reversed(models.SESSIONS[s.session_id].messages)
|
||||
if isinstance(m, dict) and m.get("role") == "user"
|
||||
)
|
||||
assert recovered["_source"] == "process_wakeup"
|
||||
assert recovered["_wakeup_meta"]["task_id"] == "proc_42"
|
||||
assert recovered["_wakeup_meta"]["exit_code"] == 1
|
||||
|
||||
|
||||
def test_cancel_stream_recovery_webui_turn_unmarked():
|
||||
stream_id = "stream-meta"
|
||||
s = Session(
|
||||
session_id="cancel-meta-2",
|
||||
title="t",
|
||||
messages=[{"role": "assistant", "content": "prior report"}],
|
||||
)
|
||||
s.pending_user_message = "ordinary message"
|
||||
s.pending_user_source = "webui"
|
||||
s.pending_attachments = []
|
||||
s.pending_started_at = None
|
||||
s.active_stream_id = stream_id
|
||||
s.save()
|
||||
models.SESSIONS[s.session_id] = s
|
||||
_wire_cancel_state(s.session_id, stream_id)
|
||||
|
||||
assert cancel_stream(stream_id) is True
|
||||
|
||||
recovered = next(
|
||||
m for m in reversed(models.SESSIONS[s.session_id].messages)
|
||||
if isinstance(m, dict) and m.get("role") == "user"
|
||||
)
|
||||
assert "_source" not in recovered
|
||||
assert "_wakeup_meta" not in recovered
|
||||
Reference in new issue
Block a user