openclaw - ✅(Solved) Fix [Bug]: GitHub Copilot provider: `rewriteCopilotConnectionBoundResponseIds` synthesises mismatched `rs_/fc_/msg_` ids and breaks every multi-turn tool-call session (HTTP 400 "item_id did not match") [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#72602Fetched 2026-04-28 06:34:06
View on GitHub
Comments
1
Participants
2
Timeline
8
Reactions
0
Author
Participants
Timeline (top)
cross-referenced ×2labeled ×2closed ×1commented ×1

In OpenClaw 2026.4.24, every GitHub Copilot session that does a tool call followed by another model turn (i.e. anything where the assistant emits a toolCall and then has to be replayed alongside the toolResult) hard-fails on the second turn with:

400 The encrypted content for item rs_<16hex> could not be verified.
Reason: Encrypted content item_id did not match the target item id.

The error is deterministic and reproducible against github-copilot/gpt-5.4 (and any other Copilot Responses model that returns reasoning items with encrypted_content).

The faulty rs_<16hex> id is synthesised by OpenClaw itself in extensions/github-copilot/connection-bound-ids.ts, not returned by Copilot. The function rewriteCopilotConnectionBoundResponseIds rewrites Copilot's original id field to <prefix>_${sha256(originalId).hex().slice(0,16)} before sending it back. Copilot then decrypts the encrypted_content blob, recovers the real connection-bound id, sees it does not equal the synthesised id, and rejects the request.

Error Message

The error is deterministic and reproducible against github-copilot/gpt-5.4 (and any other Copilot Responses model that returns reasoning items with encrypted_content).

Root Cause

extensions/github-copilot/connection-bound-ids.ts (bundle: dist/connection-bound-ids-B64ZkLq2.js):

function deriveReplacementId(type, originalId) {
    return `${type === "reasoning" ? "rs" : type === "function_call" ? "fc" : "msg"}_${createHash("sha256").update(originalId).digest("hex").slice(0, 16)}`;
}
function rewriteCopilotConnectionBoundResponseIds(input) {
    if (!Array.isArray(input)) return false;
    let rewrote = false;
    for (const item of input) {
        const id = item.id;
        if (typeof id !== "string" || id.length === 0) continue;
        if (looksLikeConnectionBoundId(id)) {
            item.id = deriveReplacementId(item.type, id);  // <-- the bug
            rewrote = true;
        }
    }
    return rewrote;
}

This is invoked from extensions/github-copilot/stream.ts wrapCopilotOpenAIResponsesStream via the onPayload hook, so it runs on every outbound /responses request right before the OpenAI SDK ships it.

The intent (judging by name) was probably "Copilot's id format isn't rs_xxx-shaped, normalise it so other code paths don't get confused". But what actually happens is:

  • looksLikeConnectionBoundId(id) correctly detects "this is a long base64-encoded id, not the rs_xxx short form".
  • deriveReplacementId(...) then builds a brand new id by hashing the original — a value Copilot has never heard of.
  • Copilot still has the encrypted blob, decrypts it, finds the real id inside (which is the original base64 we just discarded), compares to the synthesised one, and 400s.

You can verify the math directly. Take the base64 id we observed in our session jsonl:

w7iksJhUX/oP8zmJaQd0T7pjSRBNLSwpr6EHOsC5HuerZ0/xaFS6LjbWnSyUKpadjkrGp5lJK/pM2rPsNFVso4JA9Fv3A6Z4wp5hDPBJQboEkuLyhMhLk6hbpgGlSd4vyvYV4v4EHufKaa0dnUbYgYuQRfVUzb4/sF3qidjTDvljhfa8lhmBjPRj5+rDPsGziQIoB+qmZ7KAa73lEM5cJQyyIapkHPh+oBu3qWkpgLf0uAhq7KVZepGQN2tVehH+MxrX7HwDLECuw3N30h5NDETj1nu8AOMESPH6GJJR2gsjCSd1W/eVCRX6cbGWjxNdJInN7mjy4hBnJ/2FSspKuLBqOpHOnff0DQWcya6q3hg68stMS8uOojDY+xfYvYPvEJIEEbJk14Ud8O++FTht0g==
>>> import hashlib
>>> "rs_" + hashlib.sha256(b"<the base64 above>").hexdigest()[:16]
'rs_951f665be3b6e210'

That's exactly the id Copilot complains about in our 400. Bit-for-bit match → it's our hash, not Copilot's.

Fix Action

Fix / Workaround

Verified locally on 2026.4.24: applying this patch immediately unblocks every Feishu/embedded Copilot session that previously 400'd. Curl variants vB/vC confirm Copilot accepts the result.

PR fix notes

PR #72613: fix(github-copilot): also skip function_call items in connection-bound-id rewriter (#72602)

Description (problem / solution / changelog)

Fixes #72602.

Summary

PR #71684 stopped rewriting `reasoning` item IDs in `rewriteCopilotConnectionBoundResponseIds` because Copilot's `/responses` endpoint validates the replayed ID against `encrypted_content` server-side and rejects any synthesised `rs_<sha256>` value with `400 "item_id did not match the target item id"`.

The same bug class survives for `function_call` items: every multi-turn tool-call session against a GitHub Copilot Responses model fails on the second turn with the same 400 because OpenClaw is sending a freshly synthesised `fc_<sha256(originalId).hex().slice(0,16)>` instead of the opaque base64 ID Copilot recovers from `encrypted_content`.

Reporter evidence

Issue #72602 includes standalone curl variants against `api.individual.githubcopilot.com/responses` that confirm Copilot's acceptance rules:

reasoning.idfunction_call.idresult
original base64original base64200 OK
omittedoriginal base64200 OK
omittedomitted200 OK
OpenClaw `rs_<hash>`OpenClaw `fc_<hash>`400

The reporter also reproduces the exact failure ID via:

```python

"rs_" + hashlib.sha256(b"<original base64>").hexdigest()[:16] 'rs_951f665be3b6e210' # bit-for-bit match for the 400's quoted ID ```

Fix

Extend the existing `if (item.type === "reasoning") continue;` guard in `extensions/github-copilot/connection-bound-ids.ts` to also cover `function_call` items. Both item types reference server-side state bound to the original ID, so leaving the original opaque base64 ID in place lets Copilot's lookup keep working.

Tests

Updated existing test that previously expected `fc_[a-f0-9]{16}` for function_call items. Added a new test "preserves function_call IDs regardless of encrypted_content" mirroring the existing reasoning-item test from #71684.

``` Test Files 2 passed (connection-bound-ids.test.ts: 5, stream.test.ts: 6) Tests 11 passed (11) ```

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • extensions/github-copilot/connection-bound-ids.test.ts (modified, +14/-1)
  • extensions/github-copilot/connection-bound-ids.ts (modified, +8/-5)

PR #72623: fix(copilot): drop connection-bound replay ids

Description (problem / solution / changelog)

Summary

  • drop Copilot connection-bound response item ids instead of replacing them with synthetic msg_ / fc_ hashes
  • apply the same boundary behavior to opaque reasoning ids while preserving encrypted_content
  • preserve encrypted reasoning content and summary when replaying stored thinking signatures

Fixes #72602

Testing

  • pnpm exec vitest run extensions/github-copilot/connection-bound-ids.test.ts src/agents/openai-responses.reasoning-replay.test.ts src/agents/openai-ws-message-conversion.test.ts
  • pnpm exec oxfmt --check extensions/github-copilot/connection-bound-ids.ts extensions/github-copilot/connection-bound-ids.test.ts src/agents/openai-ws-message-conversion.ts src/agents/openai-responses.reasoning-replay.test.ts
  • git diff --check

Changed files

  • extensions/github-copilot/connection-bound-ids.test.ts (modified, +14/-16)
  • extensions/github-copilot/connection-bound-ids.ts (modified, +6/-16)
  • src/agents/openai-responses.reasoning-replay.test.ts (modified, +52/-1)
  • src/agents/openai-ws-message-conversion.ts (modified, +1/-2)

Code Example

400 The encrypted content for item rs_<16hex> could not be verified.
Reason: Encrypted content item_id did not match the target item id.

---



---

function deriveReplacementId(type, originalId) {
    return `${type === "reasoning" ? "rs" : type === "function_call" ? "fc" : "msg"}_${createHash("sha256").update(originalId).digest("hex").slice(0, 16)}`;
}
function rewriteCopilotConnectionBoundResponseIds(input) {
    if (!Array.isArray(input)) return false;
    let rewrote = false;
    for (const item of input) {
        const id = item.id;
        if (typeof id !== "string" || id.length === 0) continue;
        if (looksLikeConnectionBoundId(id)) {
            item.id = deriveReplacementId(item.type, id);  // <-- the bug
            rewrote = true;
        }
    }
    return rewrote;
}

---

w7iksJhUX/oP8zmJaQd0T7pjSRBNLSwpr6EHOsC5HuerZ0/xaFS6LjbWnSyUKpadjkrGp5lJK/pM2rPsNFVso4JA9Fv3A6Z4wp5hDPBJQboEkuLyhMhLk6hbpgGlSd4vyvYV4v4EHufKaa0dnUbYgYuQRfVUzb4/sF3qidjTDvljhfa8lhmBjPRj5+rDPsGziQIoB+qmZ7KAa73lEM5cJQyyIapkHPh+oBu3qWkpgLf0uAhq7KVZepGQN2tVehH+MxrX7HwDLECuw3N30h5NDETj1nu8AOMESPH6GJJR2gsjCSd1W/eVCRX6cbGWjxNdJInN7mjy4hBnJ/2FSspKuLBqOpHOnff0DQWcya6q3hg68stMS8uOojDY+xfYvYPvEJIEEbJk14Ud8O++FTht0g==

---

>>> import hashlib
>>> "rs_" + hashlib.sha256(b"<the base64 above>").hexdigest()[:16]
'rs_951f665be3b6e210'

---

function rewriteCopilotConnectionBoundResponseIds(input) {
     if (!Array.isArray(input)) return false;
     let rewrote = false;
     for (const item of input) {
         const id = item.id;
         if (typeof id !== "string" || id.length === 0) continue;
         if (looksLikeConnectionBoundId(id)) {
-            item.id = deriveReplacementId(typeof item.type === "string" ? item.type : void 0, id);
+            // Copilot recovers the connection-bound id from encrypted_content
+            // when no id is supplied. Synthesising rs_<sha256> caused 400
+            // "item_id did not match the target item id".
+            delete item.id;
             rewrote = true;
         }
     }
     return rewrote;
 }

---

function parseThinkingSignature(value) {
    if (typeof value !== "string" || value.trim().length === 0) return null;
    try {
        return toReasoningSignature(JSON.parse(value));   // <-- only keeps {type, id}
    } catch { return null; }
}

---

function parseThinkingSignature(value) {
     if (typeof value !== "string" || value.trim().length === 0) return null;
     try {
-        return toReasoningSignature(JSON.parse(value));
+        return parseReasoningItem(JSON.parse(value));
     } catch { return null; }
 }
RAW_BUFFERClick to expand / collapse

Bug type

Regression (worked before, now fails)

Beta release blocker

No

Summary

In OpenClaw 2026.4.24, every GitHub Copilot session that does a tool call followed by another model turn (i.e. anything where the assistant emits a toolCall and then has to be replayed alongside the toolResult) hard-fails on the second turn with:

400 The encrypted content for item rs_<16hex> could not be verified.
Reason: Encrypted content item_id did not match the target item id.

The error is deterministic and reproducible against github-copilot/gpt-5.4 (and any other Copilot Responses model that returns reasoning items with encrypted_content).

The faulty rs_<16hex> id is synthesised by OpenClaw itself in extensions/github-copilot/connection-bound-ids.ts, not returned by Copilot. The function rewriteCopilotConnectionBoundResponseIds rewrites Copilot's original id field to <prefix>_${sha256(originalId).hex().slice(0,16)} before sending it back. Copilot then decrypts the encrypted_content blob, recovers the real connection-bound id, sees it does not equal the synthesised id, and rejects the request.

Steps to reproduce

  1. Configure any Copilot Responses model (we use github-copilot/gpt-5.4).
  2. Provide the model with at least one tool that produces output (so the first turn ends with stopReason: "toolUse" and a reasoning item with encrypted_content).
  3. Send any user prompt that triggers the tool.
  4. The first round succeeds. The second round (sending tool_result + replayed reasoning) returns HTTP 400 from Copilot.

In our case the trigger is just sending "123" to a Feishu-bound architect agent that has 27 tools registered — the assistant always reasons + calls a tool, which forces a replay round that 400s.

Standalone curl repro

You can reproduce without OpenClaw at all by:

  • Sending a tool-using request to https://api.individual.githubcopilot.com/responses with include: ["reasoning.encrypted_content"] and stream: true.
  • Capturing the assistant's reasoning item from the stream (it has the long base64 connection-bound id).
  • Replaying it back in a second /responses call as input together with the function_call and function_call_output.

Variants we tested (all on the same valid Copilot token):

Variantreasoning.idfunction_call.idresult
vAoriginal base64original base64200 OK
vBomittedoriginal base64200 OK
vComittedomitted200 OK
vD(no reasoning item at all)original base64200 OK
vE(no reasoning item)omitted200 OK
what OpenClaw sendsrs_<sha256(orig).hex().slice(0,16)>fc_<sha256(orig).hex().slice(0,16)>400

So Copilot accepts:

  • The original base64 id verbatim, or
  • No id field at all (it recovers the id from encrypted_content).

But not an rs_<hash> id whose value disagrees with what encrypted_content carries.

Expected behavior

github copilot return the normal message

Actual behavior

openclaw said Something went wrong while processing your request. Please try again, or use /new to start a fresh session.

OpenClaw version

2026.4.24

Operating system

ubuntu24.04

Install method

npm global

Model

github-copilot/gpt-5.4

Provider / routing chain

openclaw-github-copilot/gpt-5.4

Additional provider/model setup details

No response

Logs, screenshots, and evidence

Impact and severity

No response

Additional information

Root cause

extensions/github-copilot/connection-bound-ids.ts (bundle: dist/connection-bound-ids-B64ZkLq2.js):

function deriveReplacementId(type, originalId) {
    return `${type === "reasoning" ? "rs" : type === "function_call" ? "fc" : "msg"}_${createHash("sha256").update(originalId).digest("hex").slice(0, 16)}`;
}
function rewriteCopilotConnectionBoundResponseIds(input) {
    if (!Array.isArray(input)) return false;
    let rewrote = false;
    for (const item of input) {
        const id = item.id;
        if (typeof id !== "string" || id.length === 0) continue;
        if (looksLikeConnectionBoundId(id)) {
            item.id = deriveReplacementId(item.type, id);  // <-- the bug
            rewrote = true;
        }
    }
    return rewrote;
}

This is invoked from extensions/github-copilot/stream.ts wrapCopilotOpenAIResponsesStream via the onPayload hook, so it runs on every outbound /responses request right before the OpenAI SDK ships it.

The intent (judging by name) was probably "Copilot's id format isn't rs_xxx-shaped, normalise it so other code paths don't get confused". But what actually happens is:

  • looksLikeConnectionBoundId(id) correctly detects "this is a long base64-encoded id, not the rs_xxx short form".
  • deriveReplacementId(...) then builds a brand new id by hashing the original — a value Copilot has never heard of.
  • Copilot still has the encrypted blob, decrypts it, finds the real id inside (which is the original base64 we just discarded), compares to the synthesised one, and 400s.

You can verify the math directly. Take the base64 id we observed in our session jsonl:

w7iksJhUX/oP8zmJaQd0T7pjSRBNLSwpr6EHOsC5HuerZ0/xaFS6LjbWnSyUKpadjkrGp5lJK/pM2rPsNFVso4JA9Fv3A6Z4wp5hDPBJQboEkuLyhMhLk6hbpgGlSd4vyvYV4v4EHufKaa0dnUbYgYuQRfVUzb4/sF3qidjTDvljhfa8lhmBjPRj5+rDPsGziQIoB+qmZ7KAa73lEM5cJQyyIapkHPh+oBu3qWkpgLf0uAhq7KVZepGQN2tVehH+MxrX7HwDLECuw3N30h5NDETj1nu8AOMESPH6GJJR2gsjCSd1W/eVCRX6cbGWjxNdJInN7mjy4hBnJ/2FSspKuLBqOpHOnff0DQWcya6q3hg68stMS8uOojDY+xfYvYPvEJIEEbJk14Ud8O++FTht0g==
>>> import hashlib
>>> "rs_" + hashlib.sha256(b"<the base64 above>").hexdigest()[:16]
'rs_951f665be3b6e210'

That's exactly the id Copilot complains about in our 400. Bit-for-bit match → it's our hash, not Copilot's.

Suggested fix

Drop the id field instead of synthesising a hash:

 function rewriteCopilotConnectionBoundResponseIds(input) {
     if (!Array.isArray(input)) return false;
     let rewrote = false;
     for (const item of input) {
         const id = item.id;
         if (typeof id !== "string" || id.length === 0) continue;
         if (looksLikeConnectionBoundId(id)) {
-            item.id = deriveReplacementId(typeof item.type === "string" ? item.type : void 0, id);
+            // Copilot recovers the connection-bound id from encrypted_content
+            // when no id is supplied. Synthesising rs_<sha256> caused 400
+            // "item_id did not match the target item id".
+            delete item.id;
             rewrote = true;
         }
     }
     return rewrote;
 }

(deriveReplacementId becomes dead code and can also be removed.)

Verified locally on 2026.4.24: applying this patch immediately unblocks every Feishu/embedded Copilot session that previously 400'd. Curl variants vB/vC confirm Copilot accepts the result.

Alternative considered

Sending the original base64 id verbatim (variant vA) also works and would preserve Copilot's intended id semantics, but it would also defeat the apparent intent of rewriteCopilotConnectionBoundResponseIds (which seems to want to keep these long base64 strings out of internal id paths). Dropping the id is the lowest-risk wire-format fix that keeps internal data structures untouched.

Side observation: parseThinkingSignature discards encrypted_content

While tracking this down I noticed a second, related bug in wait-for-idle-before-flush-CkZJsBmY.js:

function parseThinkingSignature(value) {
    if (typeof value !== "string" || value.trim().length === 0) return null;
    try {
        return toReasoningSignature(JSON.parse(value));   // <-- only keeps {type, id}
    } catch { return null; }
}

toReasoningSignature only returns {type, id?} — it strips out encrypted_content and summary, then the surrounding code rebuilds an empty reasoning item to send back to the model. With the connection-bound-ids fix in place this is "merely" a loss of the encrypted_content blob (and therefore the model's chain of thought) on every replay, but it's a separate bug worth fixing:

 function parseThinkingSignature(value) {
     if (typeof value !== "string" || value.trim().length === 0) return null;
     try {
-        return toReasoningSignature(JSON.parse(value));
+        return parseReasoningItem(JSON.parse(value));
     } catch { return null; }
 }

parseReasoningItem already exists in the same file and preserves encrypted_content / summary while still gating the id field through toReplayableReasoningId.

Environment

  • OpenClaw 2026.4.24
  • Node v22.22.0
  • Linux (systemd user service)
  • Provider: github-copilot, model gpt-5.4, transport: OpenAI Responses
  • Channel: Feishu (websocket DM), but root cause is provider-side and channel-agnostic

extent analysis

TL;DR

The most likely fix is to drop the id field in rewriteCopilotConnectionBoundResponseIds instead of synthesizing a hash, allowing Copilot to recover the connection-bound id from encrypted_content.

Guidance

  • Identify the rewriteCopilotConnectionBoundResponseIds function in extensions/github-copilot/connection-bound-ids.ts and apply the suggested fix by deleting the id field when looksLikeConnectionBoundId(id) is true.
  • Verify the fix by testing Copilot sessions that previously failed with a 400 error.
  • Consider removing the deriveReplacementId function as it becomes dead code after the fix.
  • Additionally, review the parseThinkingSignature function in wait-for-idle-before-flush-CkZJsBmY.js to ensure it preserves encrypted_content and summary fields.

Example

The suggested fix can be applied by modifying the rewriteCopilotConnectionBoundResponseIds function as follows:

 function rewriteCopilotConnectionBoundResponseIds(input) {
     ...
     if (looksLikeConnectionBoundId(id)) {
-            item.id = deriveReplacementId(typeof item.type === "string" ? item.type : void 0, id);
+            delete item.id;
             rewrote = true;
     }
     ...
 }

Notes

The fix assumes that Copilot can recover the connection-bound id from encrypted_content when no id is supplied. This is confirmed by the successful test cases (vB and vC) in the issue description.

Recommendation

Apply the workaround by dropping the id field in rewriteCopilotConnectionBoundResponseIds, as it is a low-risk fix that keeps internal data structures untouched.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

github copilot return the normal message

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix [Bug]: GitHub Copilot provider: `rewriteCopilotConnectionBoundResponseIds` synthesises mismatched `rs_/fc_/msg_` ids and breaks every multi-turn tool-call session (HTTP 400 "item_id did not match") [2 pull requests, 1 comments, 2 participants]