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:
b3nwandb3nw authored and GitHub committed 2026-07-23 20:52:51 +00:00
1 parent ecc47782e3
commit e93b0c352a
12 files changed
+958 -15

No files matched your search

+3
View File
@@ -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
View File
@@ -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)
+100
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+15
View File
@@ -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',
+27
View File
@@ -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
View File
@@ -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);
+255
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 "&lt;script&gt;" in html_card
assert "<b>bold</b>" not in html_card
assert "&lt;b&gt;bold&lt;/b&gt;" 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
+123
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;');}",
"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
+154
View File
@@ -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")
+174
View File
@@ -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