From f15e6234b7e1b7df0c97e9f5484957249000ef72 Mon Sep 17 00:00:00 2001 From: imi4u36d Date: Thu, 18 Jun 2026 10:05:58 +0800 Subject: [PATCH] fix(responses): merge leading system messages in user_note_safe fallback When `_downgrade_mid_system_to_user_notes` succeeds, it preserves leading system blocks as-is (`rewritten.extend(messages[start:i])`). If there are multiple leading system messages (e.g. `instructions` + input `system`/developer message), they survive as separate messages. The subsequent `_merge_consecutive_roles` only merges user/assistant roles, leaving consecutive system messages unmerged. Strict chat templates like Qwen3.6 validate that system messages only appear at the first position, so two leading system messages trigger: "System message must be at the beginning." Fix: call `_merge_consecutive_system_messages` in `unsupported_fallback` before `_merge_consecutive_roles` when the user_note_safe path succeeds. Closes #1908 --- omlx/api/utils.py | 4 ++++ tests/test_api_utils.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/omlx/api/utils.py b/omlx/api/utils.py index 6c95982ba..8386fd050 100644 --- a/omlx/api/utils.py +++ b/omlx/api/utils.py @@ -666,6 +666,10 @@ def unsupported_fallback() -> list[dict]: if unsupported_mid_system_policy == "user_note_safe": prepared = _downgrade_mid_system_to_user_notes(messages) if prepared is not None: + # _downgrade preserves leading system blocks as-is; merge + # consecutive system messages so strict templates (Qwen3.6+) + # that require a single leading system message don't fail. + prepared = _merge_consecutive_system_messages(prepared) if merge_consecutive_roles: prepared = _merge_consecutive_roles(prepared) return prepared diff --git a/tests/test_api_utils.py b/tests/test_api_utils.py index 9c4b14ba4..eab6ef200 100644 --- a/tests/test_api_utils.py +++ b/tests/test_api_utils.py @@ -2369,6 +2369,44 @@ def test_user_note_policy_refuses_multimodal_user_target(self): assert [m["role"] for m in result] == ["system", "user"] assert result[0]["content"] == "Runtime tip" + def test_user_note_policy_merges_leading_system_messages(self): + """Multiple leading system messages must be merged even when a + mid-system message triggers the user_note_safe downgrade path. + + Regression test for: Codex App Desktop sends ``instructions`` plus a + system/developer message in ``input``, producing two leading system + messages. When a later mid-system message triggers + ``_downgrade_mid_system_to_user_notes``, the leading block was + preserved as-is (two separate system messages), causing strict + templates like Qwen3.6 to reject with + "System message must be at the beginning." + """ + messages = [ + {"role": "system", "content": "You are a helpful assistant"}, + {"role": "system", "content": "Be concise"}, + {"role": "user", "content": "Hello"}, + {"role": "system", "content": "Remember this"}, + {"role": "user", "content": "Continue"}, + ] + + result = prepare_system_messages_for_template( + messages, + self.ErrorTokenizer(), + unsupported_mid_system_policy="user_note_safe", + ) + + # All leading system messages must be merged into one + assert result[0]["role"] == "system" + assert "You are a helpful assistant" in result[0]["content"] + assert "Be concise" in result[0]["content"] + # No second system message at position 1 — strict templates + # (Qwen3.6) require a single system message at the beginning. + assert result[1]["role"] != "system", ( + "Leading system messages were not merged — Qwen3.6-style " + "templates would reject this with " + "'System message must be at the beginning.'" + ) + def test_falls_back_for_unsupported_mid_system_placement(self): messages = [ {"role": "user", "content": "Hello"},