Resume replays a text-less assistant turn as `content: null` with no `tool_calls`, breaking strict chat-completions providers
- 主要语言
- Java
- 星标
- 10.5k
- 派生
- 1.5k
- 平均合并
- 1 天 11 小时
- 30 天内合并 PR
- 128
描述
## 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.
贡献指南
调研方向
从 ResumeSessionAsync 开始,跟踪持久化的 assistant.message 事件如何被序列化为重放的 chat-completions 消息。针对 Azure provider 重现一个已存储的、内容为空且不包含工具调用的 assistant 轮次,然后验证出站消息保留空内容,并且恢复请求成功,同时不破坏包含工具调用的轮次。
由索引模型根据 Issue 内容生成。
评估
- 技术栈
- azure
- 领域
- api, backend
- Issue 类型
- 缺陷
- 难度
- 3/5
- 预计耗时
- 1-2 天
- 活跃度
- 活跃
- 描述清晰度
- 基本清楚
- 新手友好度
- 58/100