diff --git a/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py index d1b42cba4..3aff0cf8d 100644 --- a/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py +++ b/tests/e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py @@ -252,6 +252,19 @@ def summarize_artifacts( (summary["done_indices"][0] for summary in reversed(response_summaries) if summary.get("done_indices")), None, ) + # A soft interrupt's follow-up response can drain past the commit (residual + # model unit), so its response.done lands after input_audio_buffer.committed. + # Anchor the "listen after last done, before commit" sandwich on the last + # turn that closed its floor strictly before the commit, not the + # globally-last done (which may sit after the commit and leave the interval empty). + last_done_before_commit_index = next( + ( + summary["done_indices"][0] + for summary in reversed(response_summaries) + if summary.get("done_indices") and commit_index is not None and summary["done_indices"][0] < commit_index + ), + None, + ) listen_indices = [index for index, event in enumerate(events) if event.get("type") == "response.listen"] effective_min_responses = min_responses if validation_mode == "response-required" else 1 enough_responses = len(response_summaries) >= effective_min_responses @@ -279,9 +292,9 @@ def summarize_artifacts( ) listen_after_last_done = last_done_index is not None and any(index > last_done_index for index in listen_indices) listen_after_response_before_commit = ( - last_done_index is not None + last_done_before_commit_index is not None and commit_index is not None - and any(last_done_index < index < commit_index for index in listen_indices) + and any(last_done_before_commit_index < index < commit_index for index in listen_indices) ) final_listen_after_commit = commit_index is not None and any(index > commit_index for index in listen_indices) transcript = "".join(str(summary.get("transcript") or "") for summary in response_summaries) diff --git a/tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py b/tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py index 3ab9b8e6d..9569078d2 100644 --- a/tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py +++ b/tests/e2e/online_serving/test_minicpmo_realtime_duplex_drivers.py @@ -540,6 +540,111 @@ def test_realtime_duplex_soft_interrupt_accepts_multi_delta_handoff_sequence(tmp assert summary["response_summaries"][1]["transcript"] == "一加一等于二。" +def test_realtime_duplex_soft_interrupt_accepts_followup_done_after_commit(tmp_path): + """A follow-up response can drain a residual model unit past the final commit. + + Sequence: ``r1.done -> listen -> r2.created -> ... -> commit -> r2.done -> listen``. + + The globally-last ``response.done`` (r2) lands *after* the commit, so anchoring + ``listen_after_response_before_commit`` on it leaves the ``(last_done, commit)`` + interval empty and fails the contract even though the turn yielded the floor + correctly. The check must anchor on the last done strictly before the commit + (r1.done), with the listen at 3.1 s satisfying the sandwich. + """ + demo = _load_soft_interrupt_demo_module() + output = tmp_path / "soft_interrupt_residual_drain" + output.mkdir() + first_response_id = "resp-first" + second_response_id = "resp-second" + events = [ + {"type": "response.listen", "_client_received_at_s": 1.0}, + {"type": "response.created", "response": {"id": first_response_id}, "_client_received_at_s": 2.0}, + {"type": "response.speak", "response_id": first_response_id, "_client_received_at_s": 2.0}, + { + "type": "response.audio.delta", + "response_id": first_response_id, + "delta": "AAAA", + "_client_received_at_s": 2.1, + }, + { + "type": "response.audio_transcript.delta", + "response_id": first_response_id, + "delta": "中国古代四大发明", + "_client_received_at_s": 2.1, + }, + { + "type": "response.audio.delta", + "response_id": first_response_id, + "delta": "AAAA", + "_client_received_at_s": 2.6, + }, + { + "type": "response.audio_transcript.delta", + "response_id": first_response_id, + "delta": "是造纸术。", + "_client_received_at_s": 2.6, + }, + {"type": "response.done", "response_id": first_response_id, "_client_received_at_s": 3.0}, + {"type": "response.listen", "_client_received_at_s": 3.1}, + {"type": "response.created", "response": {"id": second_response_id}, "_client_received_at_s": 3.5}, + {"type": "response.speak", "response_id": second_response_id, "_client_received_at_s": 3.5}, + { + "type": "response.audio.delta", + "response_id": second_response_id, + "delta": "AAAA", + "_client_received_at_s": 3.6, + }, + { + "type": "response.audio_transcript.delta", + "response_id": second_response_id, + "delta": "一加一等于", + "_client_received_at_s": 3.6, + }, + { + "type": "response.audio.delta", + "response_id": second_response_id, + "delta": "AAAA", + "_client_received_at_s": 3.7, + }, + { + "type": "response.audio_transcript.delta", + "response_id": second_response_id, + "delta": "二。", + "_client_received_at_s": 3.7, + }, + # The final commit lands while the follow-up response is still draining. + {"type": "input_audio_buffer.committed", "_client_received_at_s": 4.0}, + {"type": "response.done", "response_id": second_response_id, "_client_received_at_s": 4.5}, + {"type": "response.listen", "_client_received_at_s": 4.6}, + ] + (output / "events.jsonl").write_text( + "".join(demo.json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + (output / "result.json").write_text( + demo.json.dumps({"ok": True, "response_ids": [first_response_id, second_response_id]}), + encoding="utf-8", + ) + + summary = demo.summarize_artifacts( + output_dir=output, + validation_mode="response-required", + min_responses=2, + min_audio_deltas_per_response=2, + expect_followup_response_substring="一加一等于二", + ) + + assert summary["ok"] is True + # Anchored on r1.done (the last done before the commit), not the late r2.done. + assert summary["listen_after_response_before_commit"] is True + # listen_after_last_done still keys off the globally-last done, hence the final listen. + assert summary["listen_after_last_done"] is True + assert summary["final_listen_after_commit"] is True + assert summary["second_response_before_final_commit"] is True + assert summary["followup_response_transcript_ok"] is True + assert summary["response_summaries"][1]["transcript"] == "一加一等于二。" + + def test_realtime_duplex_soft_interrupt_model_policy_accepts_single_response(tmp_path): demo = _load_soft_interrupt_demo_module() output = tmp_path / "model_policy"