github / github/copilot-sdk

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

Aperta
#2,469 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
Lingua principale
Java
Stelle
10.5k
Fork
1.5k
Merge medio
1g 11h
PR unite (30g)
127

Descrizione

## 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):

```json
{"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.

Guida per i contributori

Apri la guida per i contributori

Direzione di ricerca

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.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
azure
Ambito
api, backend
Tipo di issue
Bug
Difficoltà
3/5
Tempo stimato
1-2 giorni
Stato di attività
Attiva
Chiarezza
Abbastanza chiara
Idoneità per principianti
58/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.