Skip to content

fix(sse): relocate directive-only messages off messages[0] - #10457

Merged
diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.50from
HouMinXi:fix/claude-directive-system-400
Aug 15, 2026
Merged

fix(sse): relocate directive-only messages off messages[0]#10457
diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.50from
HouMinXi:fix/claude-directive-system-400

Conversation

@HouMinXi

Copy link
Copy Markdown
Contributor

Problem: Anthropic rejects Claude Code-style directive messages when they land at messages[0]. Measured in production on 2026-08-15: 122x 400 in a single hour on the offical-claude combo (fleet-clients key, claude-opus-5), all with the same upstream error:

messages.0: use the top-level 'system' parameter for the initial system prompt;
the directive-only form (content: [] with output_config) is accepted at any position

Claude Code 2.1.154+ clients send directives as system-role messages with an empty content array and a message-level output_config. The mid-conversation-system passthrough (provider === "claude" + 1M-context beta models, shouldUseMidConversationSystem) deliberately keeps system-role messages inside messages[], so a directive that arrived first went upstream unchanged and 400'd. The other passthrough path (extractSystemRoleMessages) lifted the message away but silently dropped the directive's output_config, losing the client's output-format configuration.

Fixes:

  1. open-sse/handlers/chatCore/claudeSystemRole.ts — new relocateDirectiveOnlyMessages(): collects the whole leading run of empty system/developer messages and relocates the directive-only ones (empty content array + message-level output_config) past the first real (user/assistant) turn, in order, where Anthropic accepts the form ("accepted at any position"). Plain empty system messages carry nothing and are dropped. When the conversation has no real turn, the first directive's output_config is folded into the top-level parameter (an explicit top-level value wins) and the run is dropped.
  2. extractSystemRoleMessages() — no longer silently discards a directive's message-level output_config; it is folded into the top-level parameter whether or not the message also carried text blocks (an explicit top-level value wins; among several directives the first wins).
  3. open-sse/handlers/chatCore.ts — the mid-conversation-system passthrough now calls relocateDirectiveOnlyMessages().

Verification: new tests/unit/claude-directive-only-relocation.test.ts (16 tests: relocation, consecutive directives, empty-system+directive runs, walking past text system messages, developer-role directives, the no-real-turn fallback, top-level precedence, no-op cases) and tests/unit/claude-directive-midconv-passthrough.test.ts (a handleChatCore integration test with a mocked fetch asserting the directive never reaches upstream at messages[0]). Bug-injection proven at each layer: disabling the extract-fold made the preserve test fail; disabling the relocation made the move tests fail; removing the chatCore call made the integration test fail; reverting restores green. Regression suites: system-role-extraction, claude-system-role-cache-boundary, g13-combo-chatcore-golden, agentrouter-chatcore-protocols, claude-code-compatible-request, claude-code-compatible-helpers, cc-compatible-provider, claude-helper-minimax-output-config — 104/104. npm run typecheck:core clean, ESLint clean on all touched files.

⚠️ base-red inherited: base tip CI has CodeQL and Build Docker (linux/arm64, linux/amd64) failing on the upstream tree itself (run https://github.com/diegosouzapw/OmniRoute/actions/runs/31858514611) — unrelated to this change.

The upstream Messages API rejects directive-style messages (empty content
array with a message-level output_config) when they sit at messages[0] —
the initial system prompt position — while accepting the form at any other
position. Measured in production: 122x 400 on the offical-claude combo in
one hour.

The mid-conversation-system passthrough (official provider + 1M-context
beta models) keeps system-role messages inside messages[], so a directive
that arrived first went upstream unchanged. relocateDirectiveOnlyMessages()
moves the whole leading run of empty system messages: directive-only ones
past the first real turn, plain empties dropped. extractSystemRoleMessages()
now folds a directive's output_config into the top-level parameter instead
of silently discarding it.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
@HouMinXi
HouMinXi requested a review from diegosouzapw as a code owner August 15, 2026 09:46
@diegosouzapw

Copy link
Copy Markdown
Owner

Thanks @HouMinXi — this is a genuine 400 regression (122/hour on the Claude combo) and the callback is thorough: the relocation handles leading runs, null-safety, developer-role directives, and the no-real-turn fold to top-level, all backed by 12 unit tests plus an integration pass-through. Excellent work. Note the red CI is an inherited base-red (hung unit suite), not this change. 🚀

@diegosouzapw
diegosouzapw merged commit 8ff7f7d into diegosouzapw:release/v3.8.50 Aug 15, 2026
15 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants