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