[Bugfix][MiniCPM-o] Anchor duplex soft-interrupt listen sandwich before commit (#6772)

Signed-off-by: y_null <y_null@qq.com>
This commit is contained in:
y-null authored and GitHub committed 2026-08-30 03:25:43 +00:00
1 parent 21dd4b33ac
commit 99a2ae1fc8
2 files changed
+120 -2

No files matched your search

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