"""Tests for the confirm-before-act readback handshake (issue #42). Stdlib-only, like test_brain_bridge: conversation.py imports nothing heavier, so these run on any host without the model stack. The cases are grouped by what they protect. The ones that matter most are under "a confirmation has to be nothing but a confirmation" -- every one of those is a sentence that opens like approval and is not, and reading any of them as approval executes something the speaker was in the middle of changing. """ import unittest import conversation as c class TestFraming(unittest.TestCase): def test_the_request_is_delimited(self): # The request keeps its own imperative voice ("delete the draft"), so it has # to be fenced off from the instruction or it reads as a second instruction. framed = c.frame_for_readback("delete the draft") self.assertIn("<<<\ndelete the draft\n>>>", framed) def test_it_says_not_to_act(self): framed = c.frame_for_readback("send the email") self.assertIn("Do not act", framed) self.assertIn("do not do it", framed) def test_surrounding_whitespace_is_dropped(self): framed = c.frame_for_readback(" turn on the lights \n") self.assertIn("<<<\nturn on the lights\n>>>", framed) class TestConfirm(unittest.TestCase): def test_bare_yes(self): for answer in ["yes", "yeah", "yep", "yup", "correct", "right", "okay", "sure"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.CONFIRM) def test_all_right_as_two_words_or_one(self): # A standard spoken approval that neither spelling reached: "all" is in no set, # and "alright" was not listed. self.assertEqual(c.classify_confirmation("all right"), c.CONFIRM) self.assertEqual(c.classify_confirmation("alright"), c.CONFIRM) def test_punctuation_and_case(self): self.assertEqual(c.classify_confirmation("Yes!"), c.CONFIRM) self.assertEqual(c.classify_confirmation("YES."), c.CONFIRM) def test_confirmation_with_only_filler_around_it(self): for answer in [ "yes please", "um, yeah", "just do it", "yeah go ahead", "thats right", "that's right", "go ahead then", "okay sure", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.CONFIRM) class TestConfirmationMustBeOnlyConfirmation(unittest.TestCase): """The asymmetry the module is built around. Each of these opens with an acknowledgment and then says something else. Reading any of them as approval runs the un-corrected request. """ def test_yes_followed_by_a_correction(self): for answer in [ "yes but make it three", "yeah, at four instead", "right, the second one", "correct, and add milk", "okay but tomorrow", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_a_confirmation_word_inside_a_different_instruction(self): # "go" confirms alone and "do it" confirms as a phrase; both are also ordinary # speech. The unmatched words are what keep these out of the confirm class. for answer in ["go to the store", "do it again with three", "that one instead"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_contextual_approval_words_can_form_a_correction(self): for answer in [ "go right", "right go", "continue right", "yes, go right", "right now", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) self.assertEqual(c.classify_confirmation("right"), c.CONFIRM) self.assertEqual(c.classify_confirmation("go ahead"), c.CONFIRM) def test_now_changes_timing_instead_of_approving_the_readback(self): for answer in [ "right now", "yes now", "go ahead now", "do it now", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_now_after_bare_refusal_requests_another_readback(self): for answer in [ "no now", "nope now", "nah now", "negative now", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_an_unfinished_cancellation_revises(self): for answer in ["stop now and", "cancel now I mean"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_a_fragment_of_a_confirming_phrase_is_not_a_confirmation(self): # "do it" and "that's right" confirm; "do", "it", "that", and "ahead" are what # a clipped transcript leaves behind, and none of them is a word someone who # cannot see a screen says to approve an action. for answer in ["it", "that", "do", "ahead", "this"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_a_reply_that_acknowledges_nothing(self): # None of these replies gives a finished decision. for answer in ["um", "please", "uh well", "i mean"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) class TestClippedByTheSilenceEndpoint(unittest.TestCase): """The failure the capture path makes ordinary. capture_request endpoints on silence, so a speaker who says "yes, and..." and pauses to think hands the classifier the two words "yes and". Every reply here is a real sentence truncated at a real pause, and reading any of them as approval runs the request the speaker was still amending. """ def test_a_reply_ending_on_a_conjunction(self): for answer in [ "yes and", "okay and then", "yeah so", "right so", "sure but", "correct or", "yes also", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_a_reply_ending_on_a_hedge(self): for answer in ["yeah just", "okay with", "yes to"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_a_reply_ending_on_a_word_nobody_listed(self): # Why the tail check is a whitelist. These clip before the correction that was # coming ("yes, the... first one"), and every one of them read as approval when # the check was a list of words that cannot end a sentence, because articles and # pronouns were not on it. Ending on a word that is not a decision is enough. for answer in ["yes the", "yes a", "yes i", "yes i mean", "okay would"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_the_tail_check_admits_only_decisions(self): # The invariant itself, so a word added to either decision set stays able to end # a reply and one added to FILLER_WORDS does not silently become an ending. self.assertEqual(c._CAN_END_A_DECISION, c.CANCEL_WORDS | c.CONFIRM_WORDS) self.assertEqual(c._CAN_END_A_DECISION & c.FILLER_WORDS, frozenset()) def test_the_check_is_final_position_only(self): # Same words, complete sentences. Only a reply that ENDS on one is unfinished. self.assertEqual(c.classify_confirmation("just do it"), c.CONFIRM) self.assertEqual(c.classify_confirmation("and yes"), c.CONFIRM) def test_a_hedge_that_ends_a_reply_is_not_an_ending(self): # "well" used to be stripped as terminal filler, which let these confirm. It # opens a wind-up to disagree far more often than it closes a turn, so a reply # endpointed on it is the pause before "actually, make it three". for answer in ["yeah well", "okay well", "yes well"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_filler_that_does_end_a_sentence_is_stripped_first(self): # "then" finishes a reply, so it must not hide the truncation under it. self.assertEqual(c.classify_confirmation("go ahead then"), c.CONFIRM) self.assertEqual(c.classify_confirmation("never mind then"), c.CANCEL) self.assertEqual(c.classify_confirmation("okay and then"), c.REVISE) self.assertEqual(c.classify_confirmation("yes and please"), c.REVISE) def test_a_question_back_is_not_approval(self): # "sure" confirms; "you sure" is the speaker asking, not approving. self.assertEqual(c.classify_confirmation("you sure"), c.REVISE) self.assertEqual(c.classify_confirmation("thank you"), c.REVISE) self.assertEqual(c.classify_confirmation("yes thank you"), c.CONFIRM) class TestCancel(unittest.TestCase): def test_bare_refusals(self): for answer in ["no", "nope", "nah", "stop", "cancel", "nevermind"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.CANCEL) def test_contractions_fold_to_one_spelling(self): for answer in ["don't", "dont", "don’t"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.CANCEL) def test_never_mind_as_two_words(self): self.assertEqual(c.classify_confirmation("never mind"), c.CANCEL) def test_a_negated_do_it_is_a_refusal(self): # "do it" folds to a confirmation, so an unguarded fold turned the plainest # spoken refusal there is into "dont confirm" and sent it around for a revision. for answer in [ "dont do it", "don't do it", "do not do it", "no dont do it", "dont do that", "do not do that", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.CANCEL) def test_a_refusal_keeps_its_object_pronoun(self): # The whitelist tail check created these: "that" and "it" are not decision # words, so an ordinary cancellation ended on one and never reached the refusal # branch. Folding the object in is narrower than letting trailing pronouns # through, which would have made "yes that" confirm. for answer in ["cancel that", "stop it", "forget it", "scratch that"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.CANCEL) self.assertEqual(c.classify_confirmation("yes that"), c.REVISE) def test_every_object_absorbing_verb_already_decides_alone(self): # The fold leaves the verb behind, so a verb that is not itself a refusal would # produce a token no branch recognises and quietly revise. "drop it" was written # that way first and did exactly that. for verb in ["cancel", "stop", "forget", "scratch"]: with self.subTest(verb=verb): self.assertIn(verb, c.CANCEL_WORDS) def test_a_negated_do_it_carrying_a_correction_still_revises(self): # The fold decides the words, not the reply. Two different rules catch these: # "yet" is not a decision word, so the first stops at the tail check, while the # second ends on one and reaches the all-or-nothing refusal rule instead. self.assertEqual(c.classify_confirmation("dont do it yet"), c.REVISE) self.assertEqual(c.classify_confirmation("dont do it go ahead"), c.REVISE) def test_a_refusal_with_filler_still_ends_the_turn(self): for answer in [ "no thanks", "um, no", "no please stop", "never mind then", "no, don't", "stop, don't", "stop now", "cancel it now", "please stop now", "stop now please", "no, stop now", "abort now", "never mind now", "don't, don't do it", "no, no, don't", "don't, don't", "don't, no", "never, nope", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.CANCEL) def test_the_word_sets_stay_disjoint(self): # Both branches are all-or-nothing over CANCEL/CONFIRM plus FILLER, so a word # in two sets would change which branch claims a reply, silently and in # whichever direction the code happens to read. Fail here, where the cause is # visible, rather than in whichever classification test drifts. self.assertEqual(c.CANCEL_WORDS & c.CONFIRM_WORDS, frozenset()) self.assertEqual(c.CANCEL_WORDS & c.FILLER_WORDS, frozenset()) self.assertEqual(c.CONFIRM_WORDS & c.FILLER_WORDS, frozenset()) class TestNegationCarryingACorrection(unittest.TestCase): """A refusal has to be nothing but a refusal, for the mirror-image reason. Cancelling on any negation reads as the cautious choice and is not. Neither outcome executes anything, so the strict rule buys no safety; it just drops the correction and makes the speaker say the whole request again. """ def test_a_negation_with_a_replacement(self): for answer in [ "don't send it to Bob, send it to Alice", "no, make it tomorrow", "never mind the email, call her instead", "not the second one, the first one", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_negation_composes_with_the_following_decision(self): for answer in ["dont stop", "do not stop", "never stop", "dont go right"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) for answer in ["dont proceed", "do not continue", "don't go", "never proceed"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.CANCEL) def test_negation_cut_off_mid_reply_stays_a_revision(self): for answer in [ "don't, I mean", "don't proceed and", "dont the", "never i", "dont do it now", "do not do that now", "don't, stop", "no, don't, stop", "I don't mean proceed", ]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.REVISE) def test_a_reply_carrying_both_a_yes_and_a_no(self): # Ambiguous. Reading it back is how an ambiguous answer gets resolved, and it # runs nothing in the meantime. self.assertEqual(c.classify_confirmation("no, that's right"), c.REVISE) self.assertEqual(c.classify_confirmation("yes, no, stop"), c.REVISE) def test_nothing_heard(self): for answer in ["", " ", None, "...", "!!"]: with self.subTest(answer=answer): self.assertEqual(c.classify_confirmation(answer), c.CANCEL) def test_wait_leads_a_correction_not_a_refusal(self): # The one word deliberately kept out of CANCEL_WORDS. Asserted bare, because # "wait, make it three" is a revision under either reading -- the trailing # words fail the all-or-nothing cancel check on their own, so it would pass # with "wait" in the set and pin nothing. self.assertEqual(c.classify_confirmation("wait"), c.REVISE) self.assertEqual(c.classify_confirmation("wait, make it three"), c.REVISE) class TestHandshakeStep(unittest.TestCase): def test_approval_executes(self): self.assertEqual(c.handshake_step("yes"), (c.EXECUTE, None)) def test_direction_correction_cannot_execute(self): self.assertEqual(c.handshake_step("go right"), (c.REPROMPT, None)) def test_refusal_abandons_and_says_so(self): self.assertEqual(c.handshake_step("no"), (c.ABANDON, c.CANCELLED_REPLY)) def test_silence_abandons(self): self.assertEqual(c.handshake_step(None), (c.ABANDON, c.CANCELLED_REPLY)) def test_a_correction_goes_around_again(self): self.assertEqual( c.handshake_step("make it three", revisions=0), (c.REPROMPT, None) ) def test_the_revision_loop_has_a_floor(self): # Two corrections re-prompt, the third ends the turn rather than looping. A # readback that has missed twice is usually a microphone problem, and more # turns do not converge on one. self.assertEqual( c.handshake_step("make it three", revisions=0), (c.REPROMPT, None) ) self.assertEqual( c.handshake_step("make it three", revisions=1), (c.REPROMPT, None) ) self.assertEqual( c.handshake_step("make it three", revisions=2), (c.ABANDON, c.GAVE_UP_REPLY) ) def test_the_floor_is_configurable(self): self.assertEqual( c.handshake_step("make it three", revisions=0, max_revisions=1), (c.REPROMPT, None), ) self.assertEqual( c.handshake_step("make it three", revisions=1, max_revisions=1), (c.ABANDON, c.GAVE_UP_REPLY), ) def test_the_floor_never_blocks_an_approval_or_a_refusal(self): # Running out of corrections must not swallow a decision the speaker did make. self.assertEqual(c.handshake_step("yes", revisions=99), (c.EXECUTE, None)) self.assertEqual( c.handshake_step("no", revisions=99), (c.ABANDON, c.CANCELLED_REPLY) ) if __name__ == "__main__": unittest.main()