Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Resume replays a text-less assistant turn as `content: null` with no `tool_calls`, breaking strict chat-completions providers

Open
#2,469 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
58/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
azure
Domain
api, backend

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

  1. Run a turn against a BYOK Azure OpenAI provider that ends with no assistant text and no tool calls.
  2. Let the session end so the event log is persisted.
  3. 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from github/copilot-sdk

All issues in github/copilot-sdk

Similar issues

More Java issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.