hermes(voice): a stray error segment must not discard a real reply

The 'Something went wrong - listening' state with no spoken answer was a
false positive: readAssistantTurn flagged the whole turn as an error if
ANY segment was error-stamped - including a recovered/transient tool
error or a cancellation notice from an earlier interim - and threw away
the real answer that the same turn produced. Error now surfaces only
when the turn yielded no spoken answer at all; a turn with real content
is spoken normally. Softened the genuine-error label to the friendlier
'Let's try that again - listening'. New probe scenarios lock both: an
error segment alongside an answer speaks the answer (error=false), and
an error-only turn still reports the error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
jenkins 2026-08-24 16:18:48 -03:00
parent 4de37f4f3a
commit b40300efaf
4 changed files with 50 additions and 4 deletions

View File

@ -861,7 +861,7 @@
lastSentTranscript=null;
clearBargeCancellation();
setConversationAssistantCaption('');
resyncCapture(token,'Something went wrong — listening');
resyncCapture(token,'Lets 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(){

View File

@ -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');

View File

@ -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"] == "Lets 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 "Lets 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.

View File

@ -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"]