Repository navigation
[Bug]: Mid-conversation role: system messages in Claude inbound are aggregated into top-level instructions, destroying prompt cache prefix #4148
Description
Client or integration
Claude Code
Area
Proxy and routing
Summary
In src/claude/inbound.ts:334-348, OpenCodex translates incoming Claude Messages by iterating through raw.messages. When it encounters msg.role === "system", it appends the message text to systemParts, which is then joined and assigned to body.instructions:
// src/claude/inbound.ts:334-348
else if (msg.role === "system") {
const text = systemMessageText(msg.content);
if (text.length > 0) systemParts.push(text);
}
...
if (systemParts.length > 0) body.instructions = systemParts.join("\n\n");When clients like Claude Desktop or Claude Code inject dynamic system messages during a multi-turn conversation (e.g., style reminders, date changes, or subagent notices):
- Turn 1:
instructions=[topLevelSystem + reminder_1] - Turn 2:
instructions=[topLevelSystem + reminder_1 + reminder_2]
Because instructions is placed at the very head of the LLM prompt sequence, mutating instructions on every turn completely invalidates the prompt cache prefix (KV cache) for all subsequent user/assistant/tool message history.
Real-world Impact & Production Evidence
In real-world long-running Claude Desktop sessions (e.g. 100+ turns), this defect causes prompt cache reuse to freeze at the initial system prompt boundary:
- Observed historical degradation:
cachedInputTokensremained strictly frozen at 24,576 tokens across dozens of turns while total input expanded from 60,000 to 137,000 tokens. The cache hit rate collapsed from 40.7% down to 19.4%, resulting in >110,000 un-cached tokens billed at full price on every single turn. - Controlled mitigation proof: By suppressing dynamic mid-conversation reminders on the client side (
outputStyle: defaultandtotalTokensReminder: off),instructionslength growth across turns became exactly 0. In the exact same Claude Desktop 3P harness over 10 turns (70k-92k tokens),cachedInputTokenssuccessfully grew with the conversation (51k -> 70k -> 79k), achieving 96.24% ~ 99.93% cache hit rate.
Reproduction
- Start OpenCodex on port 10100.
- Send a multi-turn Claude Messages request where Turn 1 contains
messages: [{ role: "user", content: "..." }, { role: "system", content: "reminder 1" }]. - Send Turn 2 with
messages: [{ role: "user", ... }, { role: "system", ... }, { role: "assistant", ... }, { role: "user", ... }, { role: "system", content: "reminder 2" }]. - Inspect the translated Responses request
body.instructions:- Observe that Turn 1
instructionshas length L1; - Turn 2
instructionshas length L2 = L1 + delta; - The top-level instruction header has changed, breaking the prefix match for all preceding conversation history on upstream providers (Anthropic, OpenCode Go, DeepSeek, etc.).
- Observe that Turn 1
Version
2.48.0
Operating system
macOS 15 (Darwin arm64)
Provider and model
All providers routed through Claude inbound (opencode-go/muse-spark-1.3-contributor, sensenova/deepseek-v4-flash, etc.)
Logs or error output
Turn 1: input=60,153, cached=0 (0.0%)
Turn 2: input=60,444, cached=24,576 (40.7%)
Turn 6: input=65,020, cached=24,576 (37.8%)
Turn 11: input=75,423, cached=24,576 (32.6%)
Turn 21: input=84,487, cached=24,576 (29.1%)
Turn 51: input=105,858, cached=24,576 (23.2%)
Turn 101: input=137,458, cached=26,624 (19.4%)
// Note: cachedInputTokens frozen at 24,576 while input expanded to 137k.
Expected Behavior & Design Consideration
Mid-conversation role: system messages should either:
- Be mapped into the message sequence as chronological input items (e.g.
role: "developer"in Responses input) rather than hoisted into top-levelinstructions, preserving the immutable prefix; OR - Provide a deduplication/filter policy so static/identical recurring reminders do not continuously append to
instructions.
Note on downstream adapters: Care must be taken with src/adapters/openai-chat.ts:724, which currently hoists developer items back into systemParts for non-native OpenAI routes.
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
docs(devlog): re-split the round-2 lanes after the roadmap audit
lidge-jun commentedon Sep 9, 2026
리뷰 · 우선순위 70 / 80
지금
dev끝은 #4155로 들어온 post-2.49 round-2 이선(two-lane) 계획이 잡혀 있고, 이 이슈는 그 계획의 Lane A 2번(#4129 다음)으로 이미 박혀 있습니다. 문제는 Claude inbound가 대화 중간에 들어온role: system메시지를 전부 모아서 맨 위instructions에 붙인다는 점입니다. 매 턴마다 프롬프트 맨 앞이 바뀌니, 그 뒤에 쌓인 user/assistant/tool 히스토리의 프롬프트 캐시 접두사가 매번 깨집니다. 이슈에 적힌 Desktop 실측(캐시가 24,576에 고정되고 히트율이 40%대에서 19%대로 떨어짐)은 그 증상과 정확히 맞습니다.현재 체크아웃의
src/claude/inbound.ts를 보면systemParts를 만들고(322행), 최상위 system을 넣은 뒤(324행), 메시지 루프에서msg.role === "system"이면 또systemParts에 넣고(334-336행), 마지막에body.instructions = systemParts.join(...)(348행)으로 붙입니다.prompt_cache_key폴백도 같은systemParts를 해시합니다(373-394행 근처). 예전에 ChatGPT가 input의 system 아이템을 거부해서 접어 넣은 선택이었고,tests/claude-integration/claude-inbound.test.ts313 근처가 그 모양을 잠가 둔 상태입니다. round-2 유닛 문서(devlog/_plan/260910_post249_round2/030_4148_claude_system_hoist.md)는 Responses가 시간순role: "developer"아이템을 받는다고 정리했고,role: "system"을 input에 넣는 길은 ChatGPT Codex 거부·parser 재호이스트·responses forward 재접기 때문에 피하라고 적혀 있습니다.고치려면 in-messages system은
systemParts에 넣지 말고, 시간 순서대로{ type: "message", role: "developer", content: [{ type: "input_text", text }] }형태로 input에 넣어야 합니다. 최상위 Anthropicsystem만 지금처럼instructions로 두는 쪽이 계획과 맞습니다. 다만 비-native OpenAI Chat 경로의src/adapters/openai-chat.ts722-736 근처는developer를 다시systemParts로 모아 system 한 줄로 올립니다. Claude inbound만 고쳐도 Chat 계열 업스트림에서는 접두사 깨짐이 일부 남을 수 있으니, 그 경로까지 같이 볼지 Lane A PR 범위에 밝히면 좋습니다.라인 334-336 - 대화 중간의 system까지 전부 systemParts에 넣어 instructions 접두사를 매 턴 바꿉니다
라인 348 - systemParts 전체를 body.instructions에 써서 KV 캐시 접두사가 깨집니다
라인 373-394 - Desktop prompt_cache_key 폴백도 같은 systemParts를 해시해 캐시 키가 같이 돌아갑니다
경로 src/adapters/openai-chat.ts (대략 722-736) - 비-native 경로가 developer를 다시 system으로 호이스트해서 inbound만 고쳐도 Chat 업스트림에서 같은 증상이 남을 수 있습니다
경로 tests/claude-integration/claude-inbound.test.ts (대략 313) - 예전 fold-into-instructions 모양을 잠근 테스트라 수정 시 기대값을 developer 아이템 쪽으로 바꿔야 합니다
메인테이너의 판단이 필요한 지점
너의 추천
Lane A에서 #4129 다음에 이 이슈를 바로 집어라. 계획 문서의 선택(전부 developer 아이템, top-level system만 instructions)을 PR 본문에 명시하고, inbound 테스트 기대를 그에 맞게 고친 뒤 머지 후보로 올려라. openai-chat 재호이스트는 같은 PR에 넣거나, 넣지 않으면 후속 이슈로 링크만 남겨라.
이 댓글은 grok-bot이 작성했습니다
lidge-jun commentedon Sep 9, 2026
Fixed on
devby #4161, merged as5b8f1fcfa.Mid-conversation
role: "system"messages are no longer folded intoinstructions. Each one becomes a chronologicalrole: "developer"input item, so the prompt head stops changing every turn and the provider's prefix cache survives. Top-level Anthropicsystemstill flows intoinstructionsexactly as before.The choice worth naming: every in-messages system message moves, not only the ones after the first user turn. Hoisting just the leading one would have kept the old test green while still mutating
instructionswhenever a client injects a fresh leading system message each turn — which is the failure this report is about.developerrather thansystemininputis deliberate. The schema acceptssystemthere, but ChatGPT Codex rejects it,parseRequestre-hoists it, and the canonical Responses forward folds text-only system items back intoinstructions.developeris first-class and keeps timeline order.One consequence to be aware of: Anthropic and Google outbound present developer items as chronological
usermessages. That is semantic drift from privileged system text, but it is prefix-stable, which is the point of the fix.Still open and deliberately out of scope:
src/adapters/openai-chat.tsre-hoists text developer messages into a leadingsystemmessage for non-api.openai.comChat Completions, so a DeepSeek or SenseNova route still loses the prefix. Reversing that is a separate compatibility tradeoff — please open a follow-up if you need it.Evidence: exact-head CI at
799330bcfcfd44dd22b3dd253adaab87f584bf9cconcluded success —gh run view 34415035528 --exit-statusreturns 0 for Cross-platform CI, with React Doctor, Enforce PR target branch and PR hygiene also 0. Twenty-eight checks succeeded and none were cancelled.git merge-base --is-ancestor 799330bcf origin/devpasses.Closing by hand because pull requests here target
dev, so GitHub does not auto-close a linked issue.Plan and root-cause record:
devlog/_plan/260910_post249_round2/030_4148_claude_system_hoist.md.fix(claude): keep mid-conversation system messages in the timeline
docs(devlog): record the round-2 landings and the two blockers
docs(devlog): close the round-2 record at the screenshot gate
docs(devlog): re-split the round-2 lanes after the roadmap audit
fix(claude): keep mid-conversation system messages in the timeline
docs(devlog): record the round-2 landings and the two blockers
docs(devlog): close the round-2 record at the screenshot gate