diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index 6a82eefe..99d2d715 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -861,7 +861,7 @@ lastSentTranscript=null; clearBargeCancellation(); setConversationAssistantCaption(''); - resyncCapture(token,'Something went wrong — listening'); + resyncCapture(token,'Let’s try that again — listening'); } function assistantRows(){ @@ -998,7 +998,11 @@ const value=readSegmentBody(turn); if(value&&value.trim()) parts.push(value.trim()); } - return {text:cleanForSpeech(parts.join('\n\n')),error:error}; + // A stray error-stamped segment (a recovered tool error, or a + // cancellation notice from an earlier interim) must not discard a real + // answer: only surface the error state when the turn produced no + // spoken answer at all. + return {text:cleanForSpeech(parts.join('\n\n')),error:error&&parts.length===0}; } function rememberAssistantBaseline(){ diff --git a/testing/probes/hermes_voice_response_probe.js b/testing/probes/hermes_voice_response_probe.js index 9f28bf85..17c464e3 100644 --- a/testing/probes/hermes_voice_response_probe.js +++ b/testing/probes/hermes_voice_response_probe.js @@ -399,4 +399,34 @@ const results = {}; }; } +// (6) A turn with a recovered/transient error segment AND a real answer must +// speak the answer (error must NOT veto a turn that produced content). +{ + const answer = 'Here is the real answer despite a transient tool hiccup.'; + const errSeg = answerSegment(null, 'a tool call failed transiently', { idx: 1 }); + errSeg.setAttribute('data-error', '1'); + const turn = assistantTurn([errSeg, answerSegment(answer, answer, { idx: 2 })], { worklog: false }); + setTurn(turn); + const extracted = internals.collectAssistantResponse(); + results.error_segment_with_answer = { + text: extracted.text, + error: extracted.error, + speaksAnswer: extracted.text === answer, + }; +} + +// (7) A turn that is ONLY an error envelope (no answer) still reports error. +{ + const errSeg = answerSegment(null, 'The provider returned an error.', { idx: 1 }); + errSeg.setAttribute('data-error', '1'); + const turn = assistantTurn([errSeg], { worklog: false }); + setTurn(turn); + const extracted = internals.collectAssistantResponse(); + results.error_only_reports_error = { + text: extracted.text, + isEmpty: extracted.text === '', + error: extracted.error, + }; +} + 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 538430f9..e44aa66d 100644 --- a/testing/tests/test_hermes_voice_capture_continuity.py +++ b/testing/tests/test_hermes_voice_capture_continuity.py @@ -182,7 +182,7 @@ def test_two_word_young_utterance_keeps_the_hold(probe_results): def test_errored_turn_is_never_spoken_and_capture_resyncs(probe_results): scenario = probe_results["errored_turn_is_not_spoken_and_capture_resyncs"] assert scenario["stateAfterError"] == "listening" - assert scenario["labelAfterError"] == "Something went wrong — listening" + assert scenario["labelAfterError"] == "Let’s try that again — listening" assert scenario["ttsDuringError"] == 0 assert scenario["freshSessions"] >= 1 # The follow-up utterance is sent alone: no stitch with the errored turn. @@ -216,7 +216,7 @@ def test_cut_marker_error_resync_and_speaking_cycles_source_contract(): # Error envelopes are detected structurally and trigger a capture resync. assert "function handleAssistantResponseError(token)" in source assert "function resyncCapture(token,statusLabel)" in source - assert "'Something went wrong — listening'" in source + assert "Let’s try that again — listening" in source assert "segment.dataset.error==='1'" in source assert ".provider-error-details" in source # A cancellation with no in-flight stream never gates the send. diff --git a/testing/tests/test_hermes_voice_extraction.py b/testing/tests/test_hermes_voice_extraction.py index 82f26d26..a5ee6101 100644 --- a/testing/tests/test_hermes_voice_extraction.py +++ b/testing/tests/test_hermes_voice_extraction.py @@ -45,6 +45,18 @@ def results() -> dict: return json.loads(completed.stdout) +def test_error_segment_does_not_discard_a_real_answer(results): + scenario = results["error_segment_with_answer"] + assert scenario["speaksAnswer"] is True, "a stray error segment discarded the real answer" + assert scenario["error"] is False, "a turn with a real answer must not surface the error state" + + +def test_error_only_turn_still_reports_error(results): + scenario = results["error_only_reports_error"] + assert scenario["isEmpty"] is True + assert scenario["error"] is True + + 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"]