github / github/copilot-sdk

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

未关闭
#2,469 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看
主要语言
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

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。