Skip to content

fix(claude): decline message threads on translated Messages routes - #5935

Closed
kaladinhonor wants to merge 2 commits into
lidge-jun:devfrom
kaladinhonor:fix/claude-thread-unsupported
Closed

kaladinhonor wants to merge 2 commits into
lidge-jun:devfrom
kaladinhonor:fix/claude-thread-unsupported

Conversation

@kaladinhonor

@kaladinhonor kaladinhonor commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

In first-party mode, Claude Code subagents on routed (non-Claude) models lose their task, instructions and history from the second turn onward. The model receives only the latest tool result and typically replies with something like "What would you like me to do?". No error is raised.

Cause

  • Claude Code enables its message-threads beta (message-threads-2026-08-12) only against first-party Anthropic, and the first-party intercept presents exactly that.
  • From a subagent's second turn, Claude Code sends thread: {"type": "continue", "previous_message_id": ...} with only the messages after the anchor. It may also omit system and tools, because Anthropic replays them from the stored thread.
  • src/server/claude-messages.ts ignored thread on the translated path and translated the delta as if it were the whole conversation. There is no thread store on that path, so the context was silently lost.

Fix

  • On the translated path, after the native-passthrough branch and before compatibility analysis or inference, a request that carries a thread object now gets a 400 whose error.details.error_code is thread_unsupported_request. The request log records the error code claude_thread_unsupported.
  • Claude Code treats that error code as "threads unsupported". It resends the same turn with the full conversation and keeps that model stateless for the rest of the session, so the user sees no error.
  • Native passthrough is unchanged and still forwards thread to Anthropic.

Changes

  • src/claude/message-threads.ts: detection helper and the error response.
  • src/server/claude-messages.ts: the check, placed right after the native-passthrough return in both /v1/messages and /v1/messages/count_tokens. Counting a thread delta would undercount the conversation.
  • tests/claude-integration/claude-messages-thread.test.ts: new file, registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json.
  • structure/data-planes/inbound-compat.md: new section "Claude message threads on translated routes".
  • docs-site/src/content/docs/guides/claude-code.md: one paragraph in the first-party section.

Where Claude Code reads the code: checked in the Claude Code 2.1.280 binary bundled with Claude Desktop, because the string is not in public docs.

  • Its thread-error classifier takes a 400, reads details.error_code from the error object (error.error when wrapped), and maps thread_unsupported_request to its unsupported_request outcome.
  • That outcome resends the same turn stateless, without the message-threads header, and marks the agent/model pair unsupported.
  • error.code is not read on this path. The response keeps type: "invalid_request_error", so any other client still sees an ordinary Anthropic 400.

Why create is refused too: a create response on a translated route would hand Claude Code a message id to continue from. Its next continue could only be refused as well. Refusing at create costs the same single extra round-trip per subagent. It also keeps Claude Code from holding a thread anchor that no server stores.

Observed in practice: on 2.67.0 with first-party mode and a routed gpt-6-luna subagent, a subagent asked to read a file and report a secret word forgot the task after the first tool call. With this change applied, the request log shows one claude_thread_unsupported 400 per subagent. The following turns carry the full history at the configured effort, and the subagent finished the task.

Verification

Run on Windows 11 with Bun 1.3.14, with proxy environment variables unset.

  • bun test tests/claude-integration/claude-messages-thread.test.ts: 2 pass, 0 fail. This covers continue and create, streaming and non-streaming, count_tokens, the stateless resend and native passthrough.
  • The same file with the src/server/claude-messages.ts change reverted (plain dev): 1 fail (Expected: 400, Received: 200). The upstream mock received only the orphan tool result.
  • bun run typecheck: pass.
  • bun run structure:check: "structure/ SSOT checks passed".
  • bun run privacy:scan: "Privacy scan passed".
  • bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/ci-workflows/structure-ssot.test.ts: pass.
  • bun test tests/ci-workflows/repo-hygiene.test.ts --timeout 60000: 15 pass. With the default timeout, one devlog scan timed out at 5 s on this machine.
  • bun test tests/ci-workflows/file-size-ratchet.test.ts --timeout 120000: 9 pass.

Not run: the full suite. On this Windows machine, tests/claude-integration/claude-messages-endpoint.test.ts fails on untouched dev too. Its "compatibility is uniform across translated adapters" case times out in a hook with EBUSY on temp removal. The process then keeps the spend-ledger owner (SPEND_LEDGER_OWNER_HOME_CONFLICT), so every later server-starting test in the same bun test process fails. This includes the new file when it runs after it. The new file passes on its own and does not touch that path. Full-suite coverage is left to CI.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. The change adds no logging of bodies or identifiers and touches no credential path.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • Required local validation passed; commands, results, and any full-suite exception are documented.

  • I pushed my PR to a recent dev commit (at most 10 behind; a maintainer may still ask for the exact tip before merge).

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

Summary by CodeRabbit

  • Compatibility
    • Requests containing Claude message-thread state are now rejected with a clear error on translated routes, including token-counting requests. Claude Code can resend the conversation without thread state and continue.
    • Native Anthropic passthrough continues to forward threaded requests unchanged.
  • Documentation
    • Updated the Claude Code guide with details on threaded requests and how Claude Code handles the translated-route response.

@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: lidge-jun/opencodex/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 167745fb-08b4-4294-8b25-1fc02bbee65c

📥 Commits

Reviewing files that changed from the base of the PR and between f32f9aa and 98de003.

📒 Files selected for processing (7)
  • docs-site/src/content/docs/guides/claude-code.md
  • scripts/test-layout/layout.json
  • src/claude/message-threads.ts
  • src/server/claude-messages.ts
  • structure/data-planes/inbound-compat.md
  • tests/claude-integration/claude-messages-thread.test.ts
  • tests/fixtures/test-layout-expected.json

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

Translated Claude Messages and count_tokens requests with message-thread state now receive a 400 unsupported-thread response. Native Anthropic passthrough continues to forward threaded requests unchanged. Integration tests cover both routes and a stateless resend.

Changes

Claude message-thread handling

Layer / File(s) Summary
Thread detection and route handling
src/claude/message-threads.ts, src/server/claude-messages.ts, structure/data-planes/inbound-compat.md
The handlers detect a thread object after native passthrough is ruled out. Translated Messages and count_tokens requests receive a 400 error. Messages requests record claude_thread_unsupported when request log IDs are available.
Thread behavior validation and documentation
tests/claude-integration/claude-messages-thread.test.ts, scripts/test-layout/layout.json, tests/fixtures/test-layout-expected.json, docs-site/src/content/docs/guides/claude-code.md
Integration tests check thread rejection, request logging, token-count rejection, successful stateless resend, and unchanged native passthrough. The test-layout mapping and documentation describe the new coverage and behavior.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: lidge-j

Merge Risk: ⚪ Minimal · up to 98de0

Translated threaded requests are declined while native passthrough remains unchanged. No actionable merge-blocking risk is established.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 98de0

Threaded requests on translated routes now fail explicitly rather than proceeding without their earlier context. Direct provider forwarding remains unchanged. The intended automatic client retry has not been independently verified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The changed rejection is reachable by admitted callers of the two Messages endpoints. Tests show object-shaped threaded requests do not reach the translated upstream; native requests continue to forward their thread state.

Trust Boundaries and Controls

  • observed — The server does not assume ownership of native provider thread state: passthrough is considered before translated-route rejection. Non-object thread values fall outside the new detector; the evidence does not establish an introduced exploit through those invalid shapes.

Hardening Proposals

  • proposed — Verify the supported client’s actual error interpretation and full-history retry before relying on automatic recovery as the context-preservation guarantee.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 3 files. (4 skipped: 4… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: rejecting message threads on translated Claude Messages routes. It matches the implementation, tests, and documented objectives.
Full details: Docstring Coverage

Explanation

Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 3 files. (4 skipped: 4 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the bug Something isn't working label Sep 26, 2026
@lidge-jun

Copy link
Copy Markdown
Owner

리뷰 · 우선순위 68 / 80

Claude Code를 공식 Anthropic에 붙인 것처럼 켜 두면, 서브에이전트는 두 번째 말부터 대화 전체를 보내지 않습니다. thread라는 표식과, 그 표식 뒤의 새 내용만 옵니다. 시스템 설명과 도구 목록, 앞에서 시킨 일은 Anthropic이 저장해 두었다가 이어 붙입니다. 우리 쪽에서 다른 모델로 번역하는 길에는 그 저장소가 없습니다. 예전 코드는 thread를 무시하고 방금 온 조각만 번역했습니다. 모델은 할 일을 모른 채 "무엇을 할까요?"처럼 답했고, 오류도 나지 않았습니다.

이 PR은 그 번역 길에서 thread가 객체로 오면, 모델에게 보내기 전에 400을 돌려줍니다. 본문 코드는 thread_unsupported_request이고, 요청 로그 코드는 claude_thread_unsupported입니다. Claude Code는 그 코드를 "이 모델은 스레드를 못 쓴다"로 읽고, 같은 턴을 대화 전체와 함께 다시 보낸 뒤, 그 세션에서는 그 모델을 기억 없이 씁니다. 진짜 Anthropic으로 그대로 넘기는 길은 여전히 thread를 전달합니다.

바탕은 dev입니다. types.ts와 config.ts를 나누는 변경은 아닙니다. 같은 수정을 하는 다른 열린 PR은 없습니다.

라인 - src/server/claude-messages.ts handleClaudeCountTokens (1440줄 다음). /v1/messages만 400을 줍니다. /v1/messages/count_tokens는 thread가 있어도 200과 토큰 수를 줍니다. continue는 시스템 설명, 도구, 앞 대화를 빼므로, 그 수는 마지막 도구 결과만 셉니다. 메시지 거절보다 먼저 이 수를 보면, 대화가 아주 짧은 것처럼 나옵니다.

라인 - src/claude/message-threads.ts messageThreadUnsupportedResponse. 이 저장소의 다른 Anthropic 오류는 코드를 error.code에 넣습니다. 이 응답만 error.details.error_code에 넣습니다. 공개된 Claude Code 소스에서 thread_unsupported_request를 찾지 못했습니다. 작성자는 2.67.0에서 400이 한 번 난 뒤 전체 대화가 다시 왔다고 적었습니다. 클라이언트가 다른 칸을 읽으면, 다시 보내지 않고 사용자에게 400이 그대로 보입니다.

메인테이너의 판단이 필요한 지점

thread가 객체이기만 하면 거절합니다. 빈 객체, create, continue가 같습니다. create가 이미 대화 전체를 담고 있어도 400이 한 번 나고, 클라이언트가 표식 없이 다시 보냅니다. 서브에이전트마다 요청이 한 번 더 갔다 옵니다. 작성자는 그 400이 "이제부터 이 모델은 기억 없이 쓴다"는 신호라고 합니다.

thread가 없거나 null이면 예전처럼 번역합니다. 잘린 대화가 표식 없이 오면 이번 수정은 막지 못합니다.

이 PR은 아직 초안입니다. 댓글을 쓸 때 CI는 대기 중이었습니다.

너의 추천

/v1/messages에서 그대로 넘기는 길 바로 다음, 번역 전에 거절하는 위치는 맞습니다. 그 거절은 두세요. count_tokens에도 같은 400을 주면, 잘린 대화를 토큰 수로 세지 않습니다. 오류 칸은 Claude Code 2.67.0이 실제로 읽는 칸과 맞추세요. 공개 문서에는 그 문자열이 없습니다. 다른 PR은 닫지 마세요. 초안을 풀고 CI가 통과한 뒤에 머지하면 됩니다.

이 댓글은 grok-bot이 작성했습니다

@github-actions

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

✅ READY

  • all PR quality gates passed; the review readiness checklist is complete.

Review readiness checklist

  • ✅ Required local validation passed; commands, results, and any full-suite exception are documented.
  • ✅ I pushed my PR to a recent dev commit (at most 10 behind; a maintainer may still ask for the exact tip before merge).
  • ✅ I resolved all correct Codex and CodeRabbit findings.
  • ✅ My PR is ready for review.

✅ 4/4 boxes ticked.

This pull request is already Ready for Review.
The review-ready label marks this PR as ready; review automation runs independently.
Maintainers: @lidge-jun @Ingwannu

@kaladinhonor

kaladinhonor commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed 98de003:

  • count_tokens: a translated /v1/messages/count_tokens request with a thread object now gets the same thread_unsupported_request 400, so a delta is never counted as the whole conversation. Native passthrough still forwards it. The regression test covers it.
  • Error field: I checked this in the Claude Code 2.1.280 binary, since the string is not in public docs. Its thread-error classifier reads details.error_code from the 400's error object and maps thread_unsupported_request to its unsupported_request outcome. It then resends the turn stateless, without the message-threads header. error.code is not read on that path. The same field produced the one-400-then-full-history sequence in the request log on opencodex 2.67.0.
  • create: refused on purpose. A translated create would give Claude Code a message id to continue from, and that continue could only be refused as well. Refusing at create costs the same one extra round-trip per subagent, and no thread anchor is left pointing at a server that stores nothing. A null or absent thread keeps the old translation, because the client only omits context when it sends a thread object.

The PR description is updated with the above.

Claude Code enables its message-threads beta against first-party Anthropic,
which is what the first-party intercept presents. From a subagent's second
turn it sends `thread: {type: "continue", previous_message_id}` with only the
messages after the anchor and may omit `system` and `tools`, which Anthropic
replays from the stored thread.

The translated path ignored `thread` and translated the delta, so a routed
model received only the latest tool result: no task, no instructions, no
earlier turns, and no error.

Answer a `thread` object on the translated path with a 400 whose
`error.details.error_code` is `thread_unsupported_request`. Claude Code maps
that code to "unsupported", resends the turn with the full conversation and
keeps that model stateless for the session. Native passthrough still
forwards the thread unchanged.
A thread delta omits the system prompt, tools and earlier turns, so counting it
undercounts the conversation. Return the same thread_unsupported_request 400
that the translated Messages path returns.
@kaladinhonor
kaladinhonor force-pushed the fix/claude-thread-unsupported branch from cefdcc3 to 98de003 Compare September 26, 2026 12:28
@github-actions
github-actions Bot marked this pull request as ready for review September 26, 2026 12:32
lidge-jun added a commit that referenced this pull request Sep 26, 2026
)

Six focused fixes from the assigned bug batch remain as separate attributed commits.

| PR | Change | Author |
| --- | --- | --- |
| #5969 | Preserve Meta Muse tool-choice semantics and reject unsupported selectors before dispatch. | shawnkim |
| #5944 | Remove unsupported hosted web-search declarations for Xiaomi MiMo destinations. | codingbo |
| #5938 | Restart the Windows service child after unexpected exits, including exit 0, while reserving the intentional stay-out code. | codingbo |
| #5935 | Reject Claude message-thread state on translated routes so the client resends full history. | kaladinhonor |
| #5939 | Rewrite standalone `\\0` escapes in Meta tool-schema patterns to equivalent `\\x00`. | boblob6969 |
| #5951 | Preserve Kiro-reported credits across stream attempts and in the usage ledger. | codingbo |

A separate integration commit keeps upstream-controlled Kiro event-type text out of opt-in debug logs. The Kiro stream retains the previously landed bounded HTTP-error text when combined with credit metering.

Left out: #5977. Independent security review found that its local read capability authenticates the request but not the HTTP response. A substituted listener could return a shape-valid forged `protected` verdict. A correct server proof bound to the nonce, endpoint, and body is outside this batch. Both its source commit and status-validation follow-up were reverted in new commits; its test and layout entries are gone. The source PR remains open.

Co-authored-by: shawnkim <shawnkim@markncompany.co.kr>
Co-authored-by: codingbo <cnsdbo@163.com>
Co-authored-by: kaladinhonor <266145786+kaladinhonor@users.noreply.github.com>
Co-authored-by: boblob6969 <boblob6969@icloud.com>
@lidge-jun

Copy link
Copy Markdown
Owner

Thanks! This landed on dev through bug-PR merge train batch 9B, #5985 (merge bf04176). Your change is one commit on dev with you as the author and a Co-authored-by trailer. Closing since the content is now on dev.

@lidge-jun lidge-jun closed this Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working review-ready

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants