Resume replays a text-less assistant turn as `content: null` with no `tool_calls`, breaking strict chat-completions providers
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 58/100
Research direction
Start at ResumeSessionAsync and trace how persisted assistant.message events are serialized into replayed chat-completions messages. Reproduce a stored empty-content assistant turn with no tool calls against an Azure provider, then verify the outbound message preserves empty content and the resume request succeeds without breaking turns that include tool calls.
Written by the indexing model from the issue text.
Description
Summary
When a session is resumed, a persisted assistant turn that produced no text and no tool calls is replayed to the provider as {"role":"assistant","content":null} with no tool_calls. OpenAI accepts that; Azure OpenAI rejects it with 400 at messages.[N].content, so the resume fails terminally on the first request.
This is the same family as #2061 (refusal: null) and #1922 (message ordering) — an outbound chat-completions payload that only GitHub's own endpoint is lenient enough to accept.
Environment
GitHub.Copilot.SDK(.NET) 1.0.0- Copilot CLI 1.0.83-1
- BYOK,
type: "azure", chat-completions wire API
How the empty turn gets created
We use a human-in-the-loop tool: the model calls it to ask a question, and the tool result instructs the model to stop and wait. The model complies literally — it ends the turn without emitting any text. That is a reasonable thing for a model to do, and it isn't specific to our tool; any turn that ends with no text produces the same record.
The persisted event looks like this (real event, ids trimmed):
{"type":"assistant.message","data":{"messageId":"3b766c94-...","content":"","toolRequests":[],"interactionId":"c38f1310-...","outputTokens":25},"id":"9c1ad9e8-...","timestamp":"2026-05-09T00:50:55.206Z"}
...immediately followed by assistant.turn_end.
Note it is persisted as content: "", not null. The null only appears on the wire — the runtime maps the empty string to null when it serializes history for replay.
This is not rare or exotic. Scanning 252 local session directories, I found 15 occurrences of assistant.message with empty content followed directly by assistant.turn_end. (There were also ~31,800 empty assistant.message events followed by tool.execution_start — those are fine, since content: null is legal alongside tool_calls.)
Reproduction
- Run a turn against a BYOK Azure OpenAI provider that ends with no assistant text and no tool calls.
- Let the session end so the event log is persisted.
ResumeSessionAsync(sessionId)and send any message.
The first outbound request contains the offending message and fails:
400 ... messages.[N].content
Direct A/B probes against the same deployment confirm the rule:
| assistant message shape | result |
|---|---|
content: null with tool_calls |
accepted |
content: null without tool_calls |
rejected (400) |
content: "" |
accepted |
Expected
Replayed history should be valid for the configured provider. Since the event is already stored as "", the simplest fix is to stop coercing it to null when there are no tool calls — emit content: "", which every provider accepts (including OpenAI, so it doesn't regress the lenient path).
Why this needs to be fixed in the runtime
Consumers can't fix this cleanly. The runtime owns replay serialization, and there's no API to inspect or amend history before resume — so the only intervention point is editing events.jsonl on disk before calling ResumeSessionAsync, which means depending on an undocumented on-disk format and racing the runtime for the file. That's what we ended up doing, and it isn't something we want to keep.
#2405 makes the same structural point for a different payload (stale encrypted reasoning) and asks for a supported recovery path. A general "normalize outbound messages for the configured provider" pass would cover this, #2061, and probably the next one too — #2061's author notes the earlier fix for #1129 didn't generalize either.
- Dominant language
- Java
- Stars
- 10.5k
- Forks
- 1.5k
- Avg merge
- 1d 9h
- Merged PRs (30d)
- 131
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from github/copilot-sdk
-
agentic-workflows
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
github/copilot-sdk#2760 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
github/copilot-sdk#2759 ·
-
documentation
Difficulty 1/5 Under an hour Newbie friendliness 85/100
github/copilot-sdk#2758 ·
-
agentic-workflows
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
github/copilot-sdk#2709 · 1 comment ·
-
Difficulty 1/5 Under an hour Newbie friendliness 78/100
github/copilot-sdk#2673 ·
All issues in github/copilot-sdk
Similar issues
-
awaiting triage bug Causes friction Hop Gui P1 P2 Transforms
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
apache/flink-agents#1152 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
jenkinsci/blueocean-plugin#5417 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
objectionary/eo-graphs#75 ·