From 3fd760de0eda63a13ee842c324f62b9daf5cb8fe Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 15:46:09 -0300 Subject: [PATCH] hermes(voice): fix HHermesProcessed + first-sentence-stop; orb mark, lang selector Real root cause (confirmed against the live build-24 DOM): the caption and TTS extraction fell back to turn.textContent whenever a settle-frame race left no readable answer segment, scraping the avatar letter, author name and 'Processed 13s' chip - and that truncated reply made TTS speak only the first segment then drop to Listening even with the mic muted (the muted-mic first-sentence-stop). Extraction now prefers each answer segment's data-raw-text, else the answer .msg-body only (excluding thinking/tool/worklog/role chrome), and the textContent fallback is gone; a genuinely mid-flight reply retries briefly so the whole thing is read before the overlay drains. Also: the app's own caduceus mark embedded in the conversation orb as a subtle watermark; a corner language selector (Auto + en/es/ru) that forces both the STT hint and the reply voice; and a thinking affordance after 2.5s of dead time. New response probe + extraction test prove caption==body-only and that every sentence reaches the TTS queue. 278 voice-lane tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf --- dockerfiles/hermes-webui-atlas-voice.css | 180 ++++++++ dockerfiles/hermes-webui-atlas-voice.js | 324 +++++++++++++- testing/probes/hermes_voice_capture_probe.js | 53 +++ testing/probes/hermes_voice_response_probe.js | 402 ++++++++++++++++++ .../test_hermes_voice_capture_continuity.py | 88 +++- testing/tests/test_hermes_voice_extraction.py | 100 +++++ 6 files changed, 1124 insertions(+), 23 deletions(-) create mode 100644 testing/probes/hermes_voice_response_probe.js create mode 100644 testing/tests/test_hermes_voice_extraction.py diff --git a/dockerfiles/hermes-webui-atlas-voice.css b/dockerfiles/hermes-webui-atlas-voice.css index 34a96e87..70787890 100644 --- a/dockerfiles/hermes-webui-atlas-voice.css +++ b/dockerfiles/hermes-webui-atlas-voice.css @@ -427,6 +427,43 @@ opacity: 0.85; } +/* FIX 2: the Hermes caduceus mark, a static centred watermark inside the orb. + Sits above the core wash but below no energy layer's motion — it never + animates on its own and never scales past the orb, so the breathing/sweep/ + wave animations always read over it. Monochrome via currentColor at low + opacity so it stays legible against every state tint without competing with + the accent colour. Scales with the orb because it is inset-positioned. */ +.voice-conversation-orb-mark { + position: absolute; + inset: 24%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + color: rgba(233, 244, 255, 0.9); + opacity: 0.16; + mix-blend-mode: screen; + transition: opacity 200ms ease-out; +} + +.voice-conversation-orb-mark svg { + width: 100%; + height: 100%; + display: block; + filter: drop-shadow(0 0 6px rgba(var(--voice-accent), 0.3)); +} + +/* Speaking/thinking lift the watermark a touch so it feels alive with the turn, + still far below any level that would mask the animation. */ +.voice-conversation[data-voice-state="speaking"] .voice-conversation-orb-mark, +.voice-conversation[data-voice-state="thinking"] .voice-conversation-orb-mark { + opacity: 0.2; +} + +.voice-conversation.is-muted .voice-conversation-orb-mark { + opacity: 0.1; +} + /* Idle listening: slow breathing. */ .voice-conversation[data-voice-state="listening"] .voice-conversation-orb-halo { animation: voice-conversation-breathe 4.4s ease-in-out infinite; @@ -540,6 +577,117 @@ background: rgba(235, 135, 88, 0.14); } +/* FIX 3: unobtrusive language chooser in the top-right corner. A small frosted + globe that opens a compact Auto + supported-language menu; selecting forces + both the reply voice and the STT hint for the session. */ +.voice-conversation-lang { + position: absolute; + top: calc(16px + env(safe-area-inset-top, 0px)); + right: calc(16px + env(safe-area-inset-right, 0px)); + z-index: 2; +} + +.voice-conversation-lang-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + color: rgba(226, 238, 250, 0.72); + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(var(--voice-accent), 0.28); + border-radius: 50%; + cursor: pointer; + transition: color 160ms ease, background 160ms ease, border-color 160ms ease; +} + +.voice-conversation-lang-btn:hover { + color: rgba(240, 248, 255, 0.95); + background: rgba(255, 255, 255, 0.12); +} + +.voice-conversation-lang-btn:focus-visible { + outline: 2px solid rgb(var(--voice-accent)); + outline-offset: 2px; +} + +/* Forced (non-Auto) language: a small accent dot marks the active override. */ +.voice-conversation-lang-btn.is-forced { + color: rgb(var(--voice-accent)); + border-color: rgba(var(--voice-accent), 0.7); +} + +.voice-conversation-lang-btn.is-forced::after { + content: ""; + position: absolute; + top: 2px; + right: 2px; + width: 8px; + height: 8px; + border-radius: 50%; + background: rgb(var(--voice-accent)); + box-shadow: 0 0 6px rgba(var(--voice-accent), 0.8); +} + +.voice-conversation-lang-menu { + position: absolute; + top: 46px; + right: 0; + min-width: 148px; + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px; + background: rgba(16, 22, 36, 0.96); + border: 1px solid rgba(var(--voice-accent), 0.28); + border-radius: 14px; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5); + backdrop-filter: blur(8px); +} + +.voice-conversation-lang-menu[hidden] { + display: none; +} + +.voice-conversation-lang-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font: inherit; + font-size: 14px; + font-weight: 550; + text-align: left; + color: rgba(231, 241, 251, 0.9); + background: transparent; + border: 0; + border-radius: 9px; + padding: 9px 12px; + cursor: pointer; +} + +.voice-conversation-lang-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.voice-conversation-lang-item:focus-visible { + outline: 2px solid rgb(var(--voice-accent)); + outline-offset: -2px; +} + +/* The selected option carries a trailing check. */ +.voice-conversation-lang-item[aria-checked="true"] { + color: #fff; + background: rgba(var(--voice-accent), 0.16); +} + +.voice-conversation-lang-item[aria-checked="true"]::after { + content: "✓"; + font-size: 13px; + color: rgb(var(--voice-accent)); +} + .voice-conversation.is-muted .voice-conversation-orb-halo, .voice-conversation.is-muted .voice-conversation-orb-core { animation: none; @@ -551,6 +699,38 @@ filter: saturate(0.35) brightness(0.8); } +/* FIX 4: "working…" affordance. Armed by atlas-voice.js (.is-awaiting) only + after the model has been Thinking a beat with no reply text yet, so dead time + reads as alive. A slow orb shimmer plus animated dots trailing the state + caption — cheap, CSS-only, and fully suppressed under reduced motion by the + blanket rule below. */ +.voice-conversation.is-awaiting .voice-conversation-orb-halo { + animation: voice-conversation-await-shimmer 2.6s ease-in-out infinite; +} + +.voice-conversation.is-awaiting .voice-conversation-state::after { + content: ""; + display: inline-block; + width: 1.4em; + margin-left: 0.15em; + text-align: left; + vertical-align: bottom; + animation: voice-conversation-await-dots 1.4s steps(4, end) infinite; +} + +@keyframes voice-conversation-await-shimmer { + 0%, 100% { opacity: 0.6; filter: blur(10px); } + 50% { opacity: 0.92; filter: blur(13px); } +} + +@keyframes voice-conversation-await-dots { + 0% { content: ""; } + 25% { content: "·"; } + 50% { content: "··"; } + 75% { content: "···"; } + 100% { content: ""; } +} + @keyframes voice-conversation-breathe { 0%, 100% { opacity: 0.55; transform: scale(calc(0.96 + (var(--conversation-level) * 0.3))); } 50% { opacity: 0.85; transform: scale(calc(1.05 + (var(--conversation-level) * 0.3))); } diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index 94bf648e..6a82eefe 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -55,6 +55,12 @@ }catch(_){return '';} })(); const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false}; + // FIX 2: the app's own Hermes caduceus mark (static/favicon.svg), embedded + // as a centred watermark inside the conversation orb. Rendered monochrome via + // currentColor at low opacity so it reads across every state tint + // (idle/listening/transcribing/thinking/speaking) without ever obscuring the + // energy animation, and it carries no animation of its own (reduced-motion safe). + const HERMES_MARK_SVG=''; const ERROR_VISIBLE_MS=3200; const STREAMING_CAPABILITY_URL='/api/voice/streaming/capability'; const TTS_STREAM_URL='/api/tts/stream'; @@ -117,7 +123,28 @@ // detection-less turn), consumed as the NEXT streaming STT session's bias // and as the thinking-cue locale. Cleared on activate/deactivate. let sessionLanguage=''; + // Forced conversation-mode language (FIX 3): when non-empty it overrides both + // the streaming STT language hint and the reply-TTS voice, superseding + // auto-detection until the user picks Auto again. Session-scoped; no storage. + let forcedLanguage=''; + // Bounded retry budget for the completion (isFinal) pump: the STREAM_DONE + // callback can beat the settle re-render that stamps the answer onto + // data-raw-text, so a transient empty read must never tear a live reply down. + let finalizeAttempts=0; const TTS_LANGUAGES=['en','ru','es']; + // FIX 3: conversation-mode language chooser. Auto (default) plus the languages + // the private voice map supports — kept in lockstep with + // dockerfiles/hermes-jetson-tts-server.py LANGUAGE_VOICE_MAP (en → amy, + // es → claude, ru → irina). Selecting one FORCES both the reply TTS voice and + // the streaming STT language hint for the rest of the hands-free session (via + // forcedLanguage), overriding auto-detection until Auto is chosen again. + const CONVERSATION_LANGUAGES=[ + {code:'',label:'Auto',sublabel:'Detect'}, + {code:'en',label:'English',sublabel:'English'}, + {code:'es',label:'Español',sublabel:'Spanish'}, + {code:'ru',label:'Русский',sublabel:'Russian'}, + ]; + const GLOBE_ICON_SVG=''; const originalAutoRead=window.autoReadLastAssistant; const originalApplyPreference=window._applyVoiceModePref; @@ -212,11 +239,35 @@ // on deactivate, keeps no storage, and is silently skipped in environments // without a usable DOM (headless contract probes). let conversation=null; + // FIX 4: when the model has been thinking for a beat with no reply text yet, + // the overlay shows a subtle animated "working…" affordance so the dead time + // reads as alive rather than frozen. Purely a client progress hint — it never + // fabricates spoken acknowledgements (that is the model's job). + let awaitingTimer=null; + const AWAITING_AFFORDANCE_MS=2500; function conversationUsable(){ return !!(typeof document!=='undefined'&&document&&document.body&&typeof document.createElement==='function'); } + function clearAwaitingAffordance(){ + if(awaitingTimer){window.clearTimeout(awaitingTimer);awaitingTimer=null;} + if(conversation&&conversation.root.classList) conversation.root.classList.remove('is-awaiting'); + } + + function updateAwaitingAffordance(next){ + // Arm the affordance only while Thinking and only after the grace window; + // any other state, or the first scrap of reply text, disarms it. + clearAwaitingAffordance(); + if(!conversation||next!=='thinking') return; + awaitingTimer=window.setTimeout(function(){ + awaitingTimer=null; + if(conversation&&conversation.root.dataset.voiceState==='thinking'&&conversation.root.classList){ + conversation.root.classList.add('is-awaiting'); + } + },AWAITING_AFFORDANCE_MS); + } + function conversationNode(tag,className,attributes){ const node=document.createElement(tag); if(className) node.className=className; @@ -228,6 +279,7 @@ if(!conversation) return; conversation.root.dataset.voiceState=next; conversation.stateEl.textContent=customLabel||STATE_LABELS[next]||''; + updateAwaitingAffordance(next); } function updateCaptionRegion(element,text,limit){ @@ -255,7 +307,11 @@ } function setConversationAssistantCaption(text){ - if(conversation) updateCaptionRegion(conversation.assistantCaption,text,9000); + if(!conversation) return; + // The first scrap of reply text means the model is no longer silently + // thinking: disarm the "working…" affordance immediately (FIX 4). + if(text&&String(text).trim()) clearAwaitingAffordance(); + updateCaptionRegion(conversation.assistantCaption,text,9000); } function setConversationPlaying(playing){ @@ -291,12 +347,15 @@ if(!conversation) return; if(event.key==='Escape'){ if(event.preventDefault) event.preventDefault(); + // Escape peels one layer at a time: an open language menu closes first + // (focus returns to its button), and only a second Escape exits the mode. + if(conversation.langMenu&&!conversation.langMenu.hidden){closeLanguageMenu(true);return;} deactivate(true); return; } if(event.key==='Tab'){ - // Minimal focus trap across the two overlay controls. - const stops=[conversation.muteBtn,conversation.exitBtn]; + // Minimal focus trap across the overlay controls (language, mute, exit). + const stops=[conversation.langBtn,conversation.muteBtn,conversation.exitBtn].filter(Boolean); const current=stops.indexOf(document.activeElement); const index=current<0?(event.shiftKey?0:stops.length-1):current; const next=stops[(index+(event.shiftKey?stops.length-1:1))%stops.length]; @@ -305,6 +364,93 @@ } } + function closeLanguageMenu(focusButton){ + if(!conversation||!conversation.langMenu) return; + conversation.langMenu.hidden=true; + conversation.langMenu.setAttribute('hidden',''); + if(conversation.langBtn){ + conversation.langBtn.setAttribute('aria-expanded','false'); + if(focusButton&&conversation.langBtn.focus) conversation.langBtn.focus(); + } + } + + function openLanguageMenu(){ + if(!conversation||!conversation.langMenu) return; + conversation.langMenu.hidden=false; + conversation.langMenu.removeAttribute('hidden'); + if(conversation.langBtn) conversation.langBtn.setAttribute('aria-expanded','true'); + // Focus the currently selected option so keyboard users land on it. + const items=conversation.langItems||[]; + const active=items.filter(function(item){return item.getAttribute('aria-checked')==='true';})[0]||items[0]; + if(active&&active.focus) active.focus(); + } + + function toggleLanguageMenu(){ + if(!conversation||!conversation.langMenu) return; + if(conversation.langMenu.hidden) openLanguageMenu(); else closeLanguageMenu(true); + } + + function reflectLanguageSelection(){ + if(!conversation||!conversation.langItems) return; + conversation.langItems.forEach(function(item){ + const selected=(item.getAttribute('data-lang')||'')===forcedLanguage; + item.setAttribute('aria-checked',selected?'true':'false'); + }); + if(conversation.langBtn){ + const current=CONVERSATION_LANGUAGES.filter(function(entry){return entry.code===forcedLanguage;})[0]; + const label=current?current.label:'Auto'; + conversation.langBtn.setAttribute('aria-label','Conversation language: '+label); + conversation.langBtn.setAttribute('title','Language — '+label); + if(conversation.langBtn.classList){ + if(forcedLanguage) conversation.langBtn.classList.add('is-forced'); + else conversation.langBtn.classList.remove('is-forced'); + } + } + } + + function selectConversationLanguage(code){ + // Force (or, for '', release back to auto-detection) the session language. + forcedLanguage=code||''; + reflectLanguageSelection(); + // Re-bias the open streaming STT session immediately when it is safe — i.e. + // it has heard nothing yet — so the forced hint applies to the very next + // utterance instead of only the one after it. Mirrors the auto-switch path. + if(active&&captureActive&&streamingStt&&typeof streamingStt.latestPartial==='function'&&!streamingStt.latestPartial()){ + startListening(generation,{preserveDisplay:true}); + } + } + + function buildLanguageControl(){ + const wrap=conversationNode('div','voice-conversation-lang'); + const btn=conversationNode('button','voice-conversation-lang-btn',{ + type:'button','aria-haspopup':'menu','aria-expanded':'false', + 'aria-label':'Conversation language: Auto',title:'Language — Auto', + }); + btn.innerHTML=GLOBE_ICON_SVG; + const menu=conversationNode('div','voice-conversation-lang-menu',{role:'menu','aria-label':'Conversation language',hidden:''}); + menu.hidden=true; + const items=CONVERSATION_LANGUAGES.map(function(entry){ + const item=conversationNode('button','voice-conversation-lang-item',{ + type:'button',role:'menuitemradio','data-lang':entry.code, + 'aria-checked':entry.code===forcedLanguage?'true':'false', + }); + item.textContent=entry.label; + item.addEventListener('click',function(){ + selectConversationLanguage(entry.code); + closeLanguageMenu(true); + }); + menu.appendChild(item); + return item; + }); + btn.addEventListener('click',function(event){ + if(event&&event.stopPropagation) event.stopPropagation(); + toggleLanguageMenu(); + }); + wrap.appendChild(btn); + wrap.appendChild(menu); + return {wrap:wrap,btn:btn,menu:menu,items:items}; + } + function openConversationOverlay(){ if(conversation||!conversationUsable()) return; try{ @@ -315,6 +461,10 @@ orb.appendChild(conversationNode('span','voice-conversation-orb-halo')); orb.appendChild(conversationNode('span','voice-conversation-orb-core')); orb.appendChild(conversationNode('span','voice-conversation-orb-ring')); + // FIX 2: Hermes mark watermark, centred in the orb beneath the energy layers. + const orbMark=conversationNode('span','voice-conversation-orb-mark',{'aria-hidden':'true'}); + orbMark.innerHTML=HERMES_MARK_SVG; + orb.appendChild(orbMark); const stateEl=conversationNode('div','voice-conversation-state',{'aria-hidden':'true'}); const captions=conversationNode('div','voice-conversation-captions',{'aria-live':'polite'}); // Each caption is its own bounded, independently scrollable region @@ -333,6 +483,8 @@ exitBtn.textContent='Exit voice mode'; controls.appendChild(muteBtn); controls.appendChild(exitBtn); + const lang=buildLanguageControl(); + root.appendChild(lang.wrap); root.appendChild(orb); root.appendChild(stateEl); root.appendChild(captions); @@ -340,8 +492,16 @@ muteBtn.addEventListener('click',function(){if(conversation) setConversationMuted(!conversation.muted);}); exitBtn.addEventListener('click',function(){deactivate(true);}); root.addEventListener('keydown',conversationKeydown); + // A tap anywhere outside the language menu closes it (never deactivates). + root.addEventListener('click',function(event){ + if(!conversation||!conversation.langMenu||conversation.langMenu.hidden) return; + const target=event&&event.target; + const inWrap=target&&typeof target.closest==='function'&&target.closest('.voice-conversation-lang'); + if(!inWrap) closeLanguageMenu(false); + }); document.body.appendChild(root); - conversation={root:root,orb:orb,stateEl:stateEl,userCaption:userCaption,assistantCaption:assistantCaption,muteBtn:muteBtn,exitBtn:exitBtn,muted:false}; + conversation={root:root,orb:orb,stateEl:stateEl,userCaption:userCaption,assistantCaption:assistantCaption,muteBtn:muteBtn,exitBtn:exitBtn,langBtn:lang.btn,langMenu:lang.menu,langItems:lang.items,muted:false}; + reflectLanguageSelection(); syncConversationOverlay(state); if(root.focus) root.focus(); }catch(_){ @@ -353,6 +513,7 @@ } function removeConversationOverlay(){ + clearAwaitingAffordance(); const overlay=conversation; conversation=null; if(!overlay) return; @@ -645,6 +806,8 @@ removeConversationOverlay(); clearSttLanguage(); sessionLanguage=''; + forcedLanguage=''; + finalizeAttempts=0; suppressAutoRead=false; pendingStitch=null; lastSentTranscript=null; @@ -688,6 +851,7 @@ // capture so the next utterance starts a clean streaming turn. thinkingSession=null; thinkingTurnId=''; + finalizeAttempts=0; clearSttLanguage(); stopResponseObserver(); cancelThinkingCues(); @@ -729,37 +893,108 @@ return !!(typeof segment.querySelector==='function'&&segment.querySelector('.provider-error-details')); } + // Ground-truth DOM contract (verified against the live build-24 bundle, + // ui.js renderMessages / messages.js ensureAssistantRow): + // + //
+ //
+ //
H
← avatar letter + // Hermes ← author name + //
+ //
+ //
… "Processed 13s" …
← worklog chip + // ← folded interim + //
+ //
← reasoning (optional) + //
ANSWER
← the spoken answer + //
+ //
+ //
+ // + // The settled renderer stamps the CLEAN answer text (no avatar, no author, no + // "Processed Ns" chip, no reasoning) onto every answer segment's data-raw-text + // — the exact field the app's own autoReadLastAssistant() reads (ui.js:~8891). + // Reasoning uses .thinking-card-body, tool activity uses .tool-call-group-body + // and the worklog "Processed Ns" chip lives in the worklog group beside the + // segments — so .msg-body is answer-only, and the concatenated data-raw-text of + // the visible answer segments is the whole reply. The prior round's fallback to + // turn.textContent scraped the avatar "H" + "Hermes" + "Processed 13s" whenever + // a settle-frame race left no readable answer segment; that fallback is gone. + const NON_ANSWER_SEGMENT_CLASSES=['assistant-segment-worklog-source','assistant-segment-anchor']; + // A .msg-body is the spoken answer ONLY when it is not nested inside reasoning, + // tool, worklog, run-status, error or role chrome. The live renderer uses + // distinct classes for each, but this guard keeps chrome out of the caption and + // TTS even if a future build nests a body differently. + const CHROME_CONTAINER_SELECTOR='.thinking-card,.agent-activity-thinking,.tool-call-group,.tool-worklog-group,.tool-group,.tool-card,.wl-reason,.process-wakeup-card,.provider-error-details,.msg-role,.assistant-run-status,.assistant-segment-worklog-source'; + + function segmentHasClass(segment,name){ + if(!segment) return false; + if(segment.classList&&typeof segment.classList.contains==='function') return segment.classList.contains(name); + return typeof segment.className==='string'&&segment.className.split(/\s+/).indexOf(name)>=0; + } + + function segmentIsAnswer(segment){ + // A visible answer segment: not folded into the worklog, not an anchor + // scaffold, not hidden, not an error/system envelope. + if(!segment) return false; + if(segmentIsHidden(segment)) return false; + if(segmentIsError(segment)) return false; + for(let i=0;i0||speechTurn.queue.length||(speechTurn.waiters&&speechTurn.waiters.length))); + if(midReply&&finalizeAttempts<8&&active&&token===generation&&(state==='thinking'||state==='speaking')){ + finalizeAttempts+=1; + window.setTimeout(function(){pumpAssistantResponse(token,true);},120); + return; + } + finalizeAttempts=0; + if(midReply){ + finishSpeechQueue(speechTurn); + stopResponseObserver(); + thinkingSession=null;thinkingTurnId='';clearSttLanguage();cancelThinkingCues(); + return; + } + thinkingSession=null;thinkingTurnId='';clearSttLanguage();stopResponseObserver();cancelThinkingCues();restartSoon(token,250); + } return; } + finalizeAttempts=0; setConversationAssistantCaption(text); // Any answer text, even a not-yet-speakable partial clause, owns the audio // timeline from this point forward. A cue must never overlap or become part @@ -2281,9 +2542,11 @@ // falls back to the reply-text heuristic. A correction on a // detection-less turn also re-biases the sticky session language so // the next streaming STT turn recognizes the switch promptly. - const resolved=strongReplyLanguage(text)||turn.sttLanguage||detectReplyLanguage(text); + // A user-forced language (FIX 3) wins over every auto signal: the reply is + // spoken by the chosen voice regardless of script evidence or detection. + const resolved=forcedLanguage||strongReplyLanguage(text)||turn.sttLanguage||detectReplyLanguage(text); if(resolved) turn.language=resolved; - if(!turn.sttLanguage&&resolved) sessionLanguage=resolved; + if(!forcedLanguage&&!turn.sttLanguage&&resolved) sessionLanguage=resolved; } if(text.length { return { startLanguages: harness.servers.map((socket) => socket.startLanguage) }; }; +// FIX 3: choosing a language in the conversation overlay FORCES both the +// streaming STT hint and the reply TTS voice for the whole session, overriding +// auto-detection, until Auto is chosen again. +scenarios.language_override_forces_stt_and_voice = async () => { + const harness = makeHarness(); + await harness.start(); + const overlay = harness.overlay(); + if (!overlay) return { overlayPresent: false }; + const langWrap = overlay.children.find( + (child) => String(child.className).indexOf('voice-conversation-lang') >= 0, + ); + const menu = langWrap ? langWrap.children.find( + (child) => String(child.className).indexOf('voice-conversation-lang-menu') >= 0, + ) : null; + const items = menu ? menu.children : []; + const ru = items.find((item) => item.getAttribute('data-lang') === 'ru'); + const auto = items.find((item) => item.getAttribute('data-lang') === ''); + const langBtn = langWrap ? langWrap.children.find( + (child) => String(child.className).indexOf('voice-conversation-lang-btn') >= 0, + ) : null; + if (!ru) return { overlayPresent: true, ruItemPresent: false }; + // Force Russian. + ru.click(); + const forcedChecked = ru.getAttribute('aria-checked'); + const autoChecked = auto ? auto.getAttribute('aria-checked') : null; + const btnForced = langBtn ? String(langBtn.className).indexOf('is-forced') >= 0 : false; + // Run a turn with an English reply: STT hint and TTS voice must both be 'ru'. + await harness.silence(300); + await harness.speak('alpha', 1300); + await harness.silence(1500); + await harness.tick(400); + harness.setAssistantReply('A short English reply. It has two sentences.'); + harness.completeResponse(); + await harness.tick(600); + const startLanguages = harness.servers.map((socket) => socket.startLanguage); + const ttsLanguages = harness.ttsCalls.map((call) => call.language); + // Release back to Auto. + if (auto) auto.click(); + const releasedChecked = auto ? auto.getAttribute('aria-checked') : null; + const btnForcedAfterAuto = langBtn ? String(langBtn.className).indexOf('is-forced') >= 0 : false; + return { + overlayPresent: true, + ruItemPresent: true, + forcedChecked, + autoChecked, + btnForced, + startLanguages, + ttsLanguages, + releasedChecked, + btnForcedAfterAuto, + }; +}; + (async () => { const output = {}; for (const name of Object.keys(scenarios)) { diff --git a/testing/probes/hermes_voice_response_probe.js b/testing/probes/hermes_voice_response_probe.js new file mode 100644 index 00000000..9f28bf85 --- /dev/null +++ b/testing/probes/hermes_voice_response_probe.js @@ -0,0 +1,402 @@ +// Deterministic assistant-response EXTRACTION probe for +// dockerfiles/hermes-webui-atlas-voice.js. +// +// Round 3's caption/TTS extraction was validated only against a single synthetic +// {dataset:{rawText}} node, so it never exercised the REAL rendered assistant +// turn and shipped a turn.textContent fallback that scraped the avatar "H", the +// "Hermes" author name and the "Processed 13s" worklog chip into the +// conversation caption and the one-ahead TTS synthesizer ("HHermesProcessed +// 13s"), and truncated multi-sentence replies to their first sentence. +// +// This probe builds a FAITHFUL rendered turn — matching the live build-24 DOM +// (ui.js _createAssistantTurn / renderMessages, messages.js ensureAssistantRow): +// +//
+//
+//
H
+// Hermes +//
+//
+//
…Processed 13s…
+// +//
+//
REASONING
+//
ANSWER
+//
+//
+//
+// +// with a real querySelectorAll / closest / matches implementation, then drives +// the actual exported extraction + one-ahead chunker and asserts: +// (i) caption/TTS text is the answer BODY only — never "H"/"Hermes"/"Processed" +// (ii) every sentence of a multi-sentence reply reaches the chunk queue +// (iii) a pre-settle worklog-only frame extracts to '' (so the final pump +// retries rather than finalizing garbage into "Listening") +// +// node hermes_voice_response_probe.js +'use strict'; + +const fs = require('fs'); +const vm = require('vm'); + +const SCRIPT_PATH = process.argv[2]; +if (!SCRIPT_PATH) { + throw new Error('usage: hermes_voice_response_probe.js '); +} +const SOURCE = fs.readFileSync(SCRIPT_PATH, 'utf8'); + +// ── Faithful minimal DOM ──────────────────────────────────────────────────── +// Supports exactly what the extraction touches: className/classList, dataset +// (backed by data-* attributes so `[data-raw-text]` selects), hidden, recursive +// textContent, appendChild/children/parentNode, setAttribute/getAttribute, and +// querySelector(All)/closest/matches over comma-separated compound selectors of +// tag / .class / [attr] / [attr="value"] terms (no combinators — none are used). + +function parseSelector(selector) { + return String(selector).split(',').map((group) => { + const term = group.trim(); + const parts = []; + const re = /([.#]?[\w-]+)|\[([\w-]+)(?:([~|^$*]?=)"?([^"\]]*)"?)?\]/g; + let m; + while ((m = re.exec(term))) { + if (m[1]) { + if (m[1][0] === '.') parts.push({ kind: 'class', value: m[1].slice(1) }); + else if (m[1][0] === '#') parts.push({ kind: 'id', value: m[1].slice(1) }); + else parts.push({ kind: 'tag', value: m[1].toLowerCase() }); + } else if (m[2]) { + parts.push({ kind: 'attr', name: m[2], op: m[3] || null, value: m[4] }); + } + } + return parts; + }).filter((parts) => parts.length); +} + +class Node { + constructor(tag) { + this.tag = String(tag || 'div').toLowerCase(); + this.className = ''; + this.attributes = new Map(); + this.children = []; + this.parentNode = null; + this.hidden = false; + this._text = ''; + const self = this; + this.dataset = new Proxy({}, { + get(_t, key) { + if (typeof key !== 'string') return undefined; + const attr = 'data-' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); + return self.attributes.has(attr) ? self.attributes.get(attr) : undefined; + }, + set(_t, key, value) { + const attr = 'data-' + String(key).replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); + self.attributes.set(attr, String(value)); + return true; + }, + has(_t, key) { + const attr = 'data-' + String(key).replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); + return self.attributes.has(attr); + }, + }); + this.style = { setProperty() {}, removeProperty() {}, getPropertyValue() { return ''; }, display: '' }; + this.listeners = new Map(); + } + + get classList() { + const el = this; + return { + add(name) { const s = new Set(el.className.split(/\s+/).filter(Boolean)); s.add(name); el.className = [...s].join(' '); }, + remove(name) { el.className = el.className.split(/\s+/).filter((v) => v && v !== name).join(' '); }, + contains(name) { return el.className.split(/\s+/).indexOf(name) >= 0; }, + toggle(name, force) { if (force === undefined ? this.contains(name) : !force) this.remove(name); else this.add(name); }, + }; + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + if (name === 'class') this.className = String(value); + if (name === 'hidden') this.hidden = true; + } + getAttribute(name) { + if (name === 'class') return this.className || null; + return this.attributes.has(name) ? this.attributes.get(name) : null; + } + appendChild(child) { child.parentNode = this; this.children.push(child); return child; } + addEventListener(type, cb) { this.listeners.set(type, cb); } + removeEventListener(type) { this.listeners.delete(type); } + + set textContent(value) { this._text = String(value); this.children = []; } + get textContent() { + if (this.children.length) return this.children.map((c) => c.textContent).join(''); + return this._text; + } + + _matchesTerm(parts) { + return parts.every((p) => { + if (p.kind === 'class') return this.classList.contains(p.value); + if (p.kind === 'tag') return this.tag === p.value; + if (p.kind === 'id') return this.getAttribute('id') === p.value; + if (p.kind === 'attr') { + if (!this.attributes.has(p.name)) return false; + if (!p.op) return true; + return this.attributes.get(p.name) === p.value; + } + return false; + }); + } + matches(selector) { return parseSelector(selector).some((parts) => this._matchesTerm(parts)); } + + _walk(out) { for (const c of this.children) { out.push(c); c._walk(out); } return out; } + querySelectorAll(selector) { + const groups = parseSelector(selector); + return this._walk([]).filter((node) => groups.some((parts) => node._matchesTerm(parts))); + } + querySelector(selector) { const all = this.querySelectorAll(selector); return all.length ? all[0] : null; } + closest(selector) { + const groups = parseSelector(selector); + let node = this; + while (node) { if (groups.some((parts) => node._matchesTerm(parts))) return node; node = node.parentNode; } + return null; + } +} + +function el(tag, className, attrs, text) { + const node = new Node(tag); + if (className) node.setAttribute('class', className); + if (attrs) Object.keys(attrs).forEach((k) => node.setAttribute(k, attrs[k])); + if (text !== undefined) node.textContent = text; + return node; +} + +// A role header (avatar "H" + "Hermes") — the source of the "HHermes" leak. +function roleHeader() { + const role = el('div', 'msg-role assistant'); + role.appendChild(el('div', 'role-icon assistant', null, 'H')); + role.appendChild(el('span', 'msg-role-name', null, 'Hermes')); + return role; +} +// The worklog "Processed 13s" chip — the source of the "Processed 13s" leak. +function worklogGroup() { + const group = el('div', 'tool-worklog-group tool-call-group', { 'data-anchor-scene-owner': '1' }); + const summary = el('button', 'tool-call-group-summary tool-worklog-summary'); + summary.appendChild(el('span', 'tool-call-group-label', null, 'Processed')); + summary.appendChild(el('span', 'tool-call-group-duration', null, ' 13s')); + group.appendChild(summary); + const body = el('div', 'tool-call-group-body tool-worklog-body', { hidden: 'hidden' }); + body.appendChild(el('div', 'wl-reason', null, 'internal tool reasoning that must never be spoken')); + group.appendChild(body); + return group; +} +function answerSegment(rawText, bodyText, opts) { + const seg = el('div', 'assistant-segment', { 'data-msg-idx': String((opts && opts.idx) || 1) }); + if (rawText !== null && rawText !== undefined) seg.setAttribute('data-raw-text', rawText); + if (opts && opts.live) seg.setAttribute('data-live-assistant', '1'); + // Reasoning rendered INSIDE the segment uses .thinking-card-body, but pin the + // chrome exclusion by nesting a stray .msg-body inside a thinking card too. + if (opts && opts.reasoning) { + const card = el('div', 'thinking-card'); + card.appendChild(el('div', 'msg-body', null, 'REASONING: ' + opts.reasoning)); + seg.appendChild(card); + } + if (bodyText !== null && bodyText !== undefined) seg.appendChild(el('div', 'msg-body', null, bodyText)); + return seg; +} +function assistantTurn(segments, opts) { + const turn = el('div', 'msg-row assistant-turn', { 'data-role': 'assistant', 'data-session-id': 'session-1' }); + if (opts && opts.live) turn.setAttribute('id', 'liveAssistantTurn'); + turn.appendChild(roleHeader()); + const blocks = el('div', 'assistant-turn-blocks'); + if (opts && opts.worklog) blocks.appendChild(worklogGroup()); + segments.forEach((s) => blocks.appendChild(s)); + turn.appendChild(blocks); + return turn; +} + +// ── vm harness ────────────────────────────────────────────────────────────── +const documentRoot = el('div', 'msgInner'); +function makeControl(id) { + const node = new Node('div'); + node.setAttribute('id', id); + return node; +} +const controls = { + btnVoiceMode: makeControl('btnVoiceMode'), + voiceModeBar: makeControl('voiceModeBar'), + voiceModeIndicator: makeControl('voiceModeIndicator'), + voiceModeLabel: makeControl('voiceModeLabel'), + msg: makeControl('msg'), +}; + +const documentStub = { + baseURI: 'https://chat.test/', + body: new Node('body'), + getElementById: (id) => controls[id] || (id === 'liveAssistantTurn' ? documentRoot.querySelector('#liveAssistantTurn') : null), + querySelectorAll: (selector) => documentRoot.querySelectorAll(selector), + querySelector: (selector) => documentRoot.querySelector(selector), + createElement: (tag) => new Node(tag), + addEventListener() {}, + removeEventListener() {}, +}; + +const sandbox = { + console, + window: null, + document: documentStub, + navigator: { mediaDevices: { getSupportedConstraints: () => ({}), getUserMedia: async () => ({ getTracks: () => [], getAudioTracks: () => [] }) } }, + MediaRecorder: function MediaRecorder() {}, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + crypto: { getRandomValues(b) { for (let i = 0; i < b.length; i += 1) b[i] = i + 1; return b; } }, + location: { protocol: 'https:', host: 'chat.test', href: 'https://chat.test/' }, + fetch: () => Promise.reject(new Error('no network in probe')), + setTimeout: () => 0, + clearTimeout: () => {}, + setInterval: () => 0, + clearInterval: () => {}, + queueMicrotask: (fn) => Promise.resolve().then(fn), + S: { session: { session_id: 'session-1' }, busy: false, activeStreamId: null }, + URL, + Math, JSON, String, Number, Boolean, Error, Array, Object, Set, Map, + parseInt, parseFloat, isNaN, Date, +}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +sandbox.window.matchMedia = sandbox.matchMedia; +sandbox.window.MediaRecorder = sandbox.MediaRecorder; + +vm.createContext(sandbox); +vm.runInContext(SOURCE, sandbox, { filename: 'atlas-voice.js' }); + +const internals = sandbox.window.__atlasVoiceInternals; +if (!internals) { + throw new Error('atlas-voice.js did not expose __atlasVoiceInternals (early return? missing DOM stub)'); +} + +function setTurn(turn) { + documentRoot.children = []; + if (turn) documentRoot.appendChild(turn); +} + +// Mirror pumpAssistantResponse's one-ahead consume loop over a full reply: a +// partial pass (isFinal=false) then the completion pass (isFinal=true) + tail +// flush. Proves every sentence is chunked, not just sentence one. +function pumpChunks(text) { + let consumed = 0; + let first = true; + const chunks = []; + for (let pass = 0; pass < 12; pass += 1) { + const remaining = text.slice(consumed).replace(/^\s+/, ''); + const skipped = text.slice(consumed).length - remaining.length; + const ex = internals.adaptiveChunks(remaining, false, first); + if (!ex.chunks.length) break; + first = false; + consumed += skipped + ex.consumed; + chunks.push(...ex.chunks); + } + // completion pass + { + const remaining = text.slice(consumed).replace(/^\s+/, ''); + const skipped = text.slice(consumed).length - remaining.length; + const ex = internals.adaptiveChunks(remaining, true, first); + if (ex.chunks.length) { consumed += skipped + ex.consumed; chunks.push(...ex.chunks); } + const tail = text.slice(consumed).trim(); + if (tail) { chunks.push(tail); consumed = text.length; } + } + return { chunks, consumed, covered: chunks.join(' ') }; +} + +const results = {}; + +// (1) Finalized multi-segment turn: caption/TTS is answer-only, all sentences chunked. +{ + const answer = 'Sentence one is here. Sentence two follows it. And sentence three concludes.'; + const turn = assistantTurn([ + answerSegment('earlier interim answer', 'earlier interim answer', { idx: 0 }), + answerSegment(answer, answer, { idx: 1, reasoning: 'let me think' }), + ], { worklog: true }); + // fold the interim segment into the worklog exactly as the settle renderer does + const interim = turn.querySelectorAll('.assistant-segment')[0]; + interim.setAttribute('class', 'assistant-segment assistant-segment-worklog-source'); + interim.setAttribute('aria-hidden', 'true'); + interim.hidden = true; + setTurn(turn); + const extracted = internals.collectAssistantResponse(); + const pumped = pumpChunks(extracted.text); + results.finalized_multi_segment = { + text: extracted.text, + error: extracted.error, + leaksAvatar: /HHermes|Hermes/.test(extracted.text), + leaksProcessed: /Processed|13s/.test(extracted.text), + leaksReasoning: /REASONING/.test(extracted.text), + leaksInterim: /interim/.test(extracted.text), + chunkCount: pumped.chunks.length, + chunkConsumedAll: pumped.consumed === extracted.text.length, + sentenceOnePresent: /one/.test(pumped.covered), + sentenceTwoPresent: /two/.test(pumped.covered), + sentenceThreePresent: /three/.test(pumped.covered), + }; +} + +// (2) Pre-settle worklog-only frame (the STREAM_DONE-beats-settle race): the +// answer segment is not rendered yet — role header + "Processed 13s" only. +// Must extract to '' so the final pump RETRIES instead of speaking chrome. +{ + const turn = assistantTurn([], { worklog: true }); + setTurn(turn); + const extracted = internals.collectAssistantResponse(); + results.presettle_worklog_only = { + text: extracted.text, + isEmpty: extracted.text === '', + error: extracted.error, + }; +} + +// (3) Live streaming segment (no data-raw-text yet) — read via .msg-body. +{ + const partial = 'The reply is still streaming right now'; + const turn = assistantTurn([answerSegment(null, partial, { idx: 1, live: true })], { worklog: false, live: true }); + setTurn(turn); + const extracted = internals.collectAssistantResponse(); + results.live_streaming_reads_body = { + text: extracted.text, + matches: extracted.text === partial, + leaksAvatar: /Hermes/.test(extracted.text), + }; +} + +// (4) Reasoning + worklog chrome nested with stray .msg-body must be excluded. +{ + const answer = 'Only this answer body should be spoken aloud.'; + const seg = answerSegment(null, answer, { idx: 1, reasoning: 'hidden chain of thought' }); + const turn = assistantTurn([seg], { worklog: true }); + setTurn(turn); + const extracted = internals.collectAssistantResponse(); + results.chrome_excluded = { + text: extracted.text, + matches: extracted.text === answer, + leaksReasoning: /REASONING|chain of thought/.test(extracted.text), + }; +} + +// (5) Streaming growth then finalize: extraction grows monotonically to the full +// reply — the queue keeps receiving sentences until the turn is final. +{ + const two = 'First sentence. Second sentence.'; + const four = 'First sentence. Second sentence. Third sentence. Fourth sentence.'; + const liveTurn = assistantTurn([answerSegment(null, two, { idx: 1, live: true })], { live: true }); + setTurn(liveTurn); + const partial = internals.collectAssistantResponse().text; + const settledTurn = assistantTurn([answerSegment(four, four, { idx: 1 })], { worklog: true }); + setTurn(settledTurn); + const full = internals.collectAssistantResponse().text; + const pumped = pumpChunks(full); + results.streaming_growth = { + partial, + full, + grows: full.length > partial.length && full.startsWith(partial), + allFourChunked: /First/.test(pumped.covered) && /Second/.test(pumped.covered) && /Third/.test(pumped.covered) && /Fourth/.test(pumped.covered), + consumedAll: pumped.consumed === full.length, + }; +} + +process.stdout.write(JSON.stringify(results, null, 2) + '\n'); diff --git a/testing/tests/test_hermes_voice_capture_continuity.py b/testing/tests/test_hermes_voice_capture_continuity.py index 7c4319ee..538430f9 100644 --- a/testing/tests/test_hermes_voice_capture_continuity.py +++ b/testing/tests/test_hermes_voice_capture_continuity.py @@ -256,9 +256,95 @@ def test_reply_language_stickiness_source_contract(): source = VOICE_SCRIPT.read_text(encoding="utf-8") assert "let sessionLanguage=''" in source - assert "language:sessionLanguage||'auto'" in source + # A user-forced conversation-mode language (FIX 3) overrides the sticky + # auto-detected hint for the streaming STT session. + assert "language:forcedLanguage||sessionLanguage||'auto'" in source assert "function strongReplyLanguage(text)" in source assert "function detectReplyLanguage(text)" in source assert "scheduleThinkingCues(token,language||sessionLanguage,thinkingTurnId)" in source # The sticky language resets with each hands-free session. assert source.count("sessionLanguage='';") >= 2 + + +def test_conversation_language_override_forces_stt_and_voice(probe_results): + """FIX 3: picking Russian in the overlay forces BOTH the streaming STT hint + and the reply TTS voice for the session (overriding auto-detection), and Auto + releases the override.""" + scenario = probe_results["language_override_forces_stt_and_voice"] + assert scenario["overlayPresent"] is True + assert scenario["ruItemPresent"] is True + # Selection state is reflected accessibly (menuitemradio aria-checked). + assert scenario["forcedChecked"] == "true" + assert scenario["autoChecked"] == "false" + assert scenario["btnForced"] is True + # Every STT session opened after the override carries the forced hint, and an + # ENGLISH reply is still spoken by the Russian voice — auto-detection is + # fully overridden. + assert scenario["startLanguages"][1:] == ["ru", "ru", "ru"] + assert scenario["ttsLanguages"] == ["ru", "ru"] + # Auto releases the override. + assert scenario["releasedChecked"] == "true" + assert scenario["btnForcedAfterAuto"] is False + + +def test_conversation_language_selector_source_and_style_contract(): + """FIX 3: the corner language control is present, accessible, session-only + (no localStorage), and lists Auto + the server voice-map languages.""" + source = VOICE_SCRIPT.read_text(encoding="utf-8") + css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") + + assert "let forcedLanguage=''" in source + assert "const CONVERSATION_LANGUAGES=[" in source + # Auto + en/es/ru mirror hermes-jetson-tts-server.py LANGUAGE_VOICE_MAP. + for code in ("{code:'',label:'Auto'", "code:'en'", "code:'es'", "code:'ru'"): + assert code in source + assert "function selectConversationLanguage(code)" in source + assert "aria-haspopup" in source and "'aria-expanded':'false'" in source + assert "role:'menuitemradio'" in source + # Forced language overrides both STT (start hint) and the reply voice. + assert "language:forcedLanguage||sessionLanguage||'auto'" in source + assert "const resolved=forcedLanguage||strongReplyLanguage(text)" in source + # Session-only: the forced language never touches localStorage. + assert "localStorage" not in source.split("CONVERSATION_LANGUAGES", 1)[1].split("function openConversationOverlay", 1)[0] + # Escape peels the menu before exiting; the language button joins the trap. + assert "if(conversation.langMenu&&!conversation.langMenu.hidden){closeLanguageMenu(true);return;}" in source + assert "[conversation.langBtn,conversation.muteBtn,conversation.exitBtn]" in source + for token in (".voice-conversation-lang-btn", ".voice-conversation-lang-menu", "menuitemradio"): + pass # menu roles live in JS; assert CSS hooks below + for token in (".voice-conversation-lang", ".voice-conversation-lang-btn", ".voice-conversation-lang-menu", ".voice-conversation-lang-item"): + assert token in css + + +def test_conversation_orb_hermes_mark_source_and_style_contract(): + """FIX 2: the Hermes caduceus mark is embedded in the orb as a static, + low-opacity watermark that respects reduced motion and never animates.""" + source = VOICE_SCRIPT.read_text(encoding="utf-8") + css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") + + assert "const HERMES_MARK_SVG=" in source + assert 'fill-rule="evenodd"' in source # the real favicon caduceus path + assert "'voice-conversation-orb-mark'" in source + assert "orbMark.innerHTML=HERMES_MARK_SVG" in source + assert ".voice-conversation-orb-mark" in css + # Monochrome via currentColor at low opacity; carries no animation of its own. + assert "color: rgba(233, 244, 255, 0.9)" in css + mark_rule = css.split(".voice-conversation-orb-mark {", 1)[1].split("}", 1)[0] + assert "animation" not in mark_rule + assert "opacity: 0.16" in mark_rule + + +def test_conversation_thinking_working_affordance_contract(): + """FIX 4: a delayed 'working…' affordance during silent Thinking, CSS-driven + and reduced-motion aware, with no fabricated spoken acknowledgement.""" + source = VOICE_SCRIPT.read_text(encoding="utf-8") + css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") + + assert "const AWAITING_AFFORDANCE_MS=2500" in source + assert "function updateAwaitingAffordance(next)" in source + assert "conversation.root.classList.add('is-awaiting')" in source + # The affordance disarms the instant any reply text arrives. + assert "if(text&&String(text).trim()) clearAwaitingAffordance();" in source + assert ".voice-conversation.is-awaiting" in css + # Reduced motion suppresses every overlay animation, including this one. + rm_block = css.split("@media (prefers-reduced-motion: reduce)", 1)[1] + assert "animation: none !important" in rm_block diff --git a/testing/tests/test_hermes_voice_extraction.py b/testing/tests/test_hermes_voice_extraction.py new file mode 100644 index 00000000..82f26d26 --- /dev/null +++ b/testing/tests/test_hermes_voice_extraction.py @@ -0,0 +1,100 @@ +"""Assistant-response extraction contract for conversation-mode captions + TTS. + +Round 3's caption/TTS extraction was validated only against a single synthetic +``{dataset:{rawText}}`` node, so it never exercised the REAL rendered assistant +turn and shipped a ``turn.textContent`` fallback that scraped the avatar "H", the +"Hermes" author name and the "Processed 13s" worklog chip into the conversation +caption and the one-ahead TTS synthesizer ("HHermesProcessed 13s"), and truncated +multi-sentence replies to their first sentence. + +``hermes_voice_response_probe.js`` builds a faithful rendered turn matching the +live build-24 DOM (ui.js ``_createAssistantTurn`` / ``renderMessages``, messages.js +``ensureAssistantRow``) with a real ``querySelectorAll`` / ``closest`` and drives the +actual exported extraction + one-ahead chunker. These tests assert the three +symptoms are fixed and locked. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +VOICE_SCRIPT = ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js" +PROBE = ROOT / "testing" / "probes" / "hermes_voice_response_probe.js" + + +@pytest.fixture(scope="module") +def results() -> dict: + node = shutil.which("node") + if not node: + pytest.skip("node is required to drive the response extraction contract") + completed = subprocess.run( + [node, str(PROBE), str(VOICE_SCRIPT)], + check=False, + capture_output=True, + text=True, + timeout=120, + ) + assert completed.returncode == 0, completed.stderr + return json.loads(completed.stdout) + + +def test_caption_is_answer_body_only(results): + """Symptom 1: caption/TTS text must be the answer body, never row chrome.""" + scenario = results["finalized_multi_segment"] + assert scenario["text"] == ( + "Sentence one is here. Sentence two follows it. And sentence three concludes." + ) + assert scenario["leaksAvatar"] is False, "avatar 'H' / 'Hermes' leaked into caption" + assert scenario["leaksProcessed"] is False, "'Processed 13s' worklog chip leaked" + assert scenario["leaksReasoning"] is False, "reasoning leaked into the spoken answer" + assert scenario["leaksInterim"] is False, "folded interim segment leaked" + assert scenario["error"] is False + + +def test_every_sentence_reaches_the_tts_queue(results): + """Symptom 2: the one-ahead chunker enqueues the WHOLE reply, not sentence one.""" + scenario = results["finalized_multi_segment"] + assert scenario["chunkConsumedAll"] is True, "chunker left part of the reply unspoken" + assert scenario["sentenceOnePresent"] is True + assert scenario["sentenceTwoPresent"] is True + assert scenario["sentenceThreePresent"] is True + assert scenario["chunkCount"] >= 3 + + +def test_presettle_worklog_only_extracts_empty(results): + """Symptom 3 root: a pre-settle worklog-only frame must extract to '' so the + completion pump retries rather than finalizing 'HHermesProcessed 13s' into + 'Listening' after the first sentence.""" + scenario = results["presettle_worklog_only"] + assert scenario["isEmpty"] is True, f"chrome leaked pre-settle: {scenario['text']!r}" + assert scenario["error"] is False + + +def test_live_streaming_reads_message_body(results): + """While streaming, the live segment has no data-raw-text yet — the answer + still comes from its .msg-body, never the row textContent.""" + scenario = results["live_streaming_reads_body"] + assert scenario["matches"] is True + assert scenario["leaksAvatar"] is False + + +def test_reasoning_and_worklog_chrome_excluded(results): + scenario = results["chrome_excluded"] + assert scenario["matches"] is True + assert scenario["leaksReasoning"] is False + + +def test_extraction_grows_to_full_reply(results): + """Extraction grows monotonically from the streaming partial to the full + settled reply, and every settled sentence reaches the queue.""" + scenario = results["streaming_growth"] + assert scenario["grows"] is True + assert scenario["allFourChunked"] is True + assert scenario["consumedAll"] is True