Repository navigation
ACP: a retried model call streams the failed attempt's partial answer, the retry notice as answer text, then the full answer #5266
Description
Describe the bug
In --acp mode, when a model call fails partway through an answer and the CLI retries it, the ACP client receives:
- the failed attempt's partial answer;
Info: Request failed due to a transient API error. Retrying...as ordinary answer text;- the retry's full answer.
All three arrive as agent_message_chunk updates with no messageId. A client can't tell which text belongs to the abandoned attempt, so it shows, and saves, all three run together:
This partial answer is cut off by the providerInfo: Request failed due to a transient API error. Retrying...This is the retried answer.
When every retry fails, the turn still ends with stopReason: "end_turn" and no error. The failure arrives only as answer text: Error: Failed to get response from the AI model; retried 5 times (...).
Frequency: every time the model provider fails mid-stream. We hit it in normal use (HTTP 500 on the Responses WebSocket), and it reproduces every time against a local provider.
Affected version
GitHub Copilot CLI 1.0.102-1, Windows x64.
Steps to reproduce the behavior
- Run an OpenAI-compatible provider. Its first
/chat/completionsstream sends a few content chunks, thendata: {"error":{"message":"Internal server error","type":"server_error","code":"internal_error"}}; its second call succeeds. A script is below. - Start
copilot --acp --stdiowithCOPILOT_PROVIDER_BASE_URL=http://127.0.0.1:18741/v1,COPILOT_MODEL=fake-modelandCOPILOT_OFFLINE=true. - Send
initializeandsession/new, thensession/promptwith any text. - Watch the
session/updatenotifications.
Observed with the script below: 9 agent_message_chunk updates of the partial answer, then one chunk Info: Request failed due to a transient API error. Retrying..., then 5 chunks of the retry's answer, then end_turn.
Variations of the same script (dropping the stream, returning HTTP 500, failing every call) give:
| Provider behaviour | What the ACP client receives |
|---|---|
| Error event mid-stream | partial chunks, then Info: Request failed due to a transient API error. Retrying..., then the retry's chunks |
| Stream dropped mid-stream | partial chunks, then Info: Response was interrupted due to a server error. Retrying..., then the retry's chunks |
| HTTP 500 before streaming | the notice, then the retry's chunks |
| Every call fails | five notices, then Error: Failed to get response from the AI model; retried 5 times (total retry wait time: 15.63 seconds) Last error: 500 ..., then stopReason: "end_turn" |
Fake provider (Bun)
// bun fake-provider.ts → first completion fails mid-stream, later ones succeed
let calls = 0;
const sse = (o: unknown) => `data: ${JSON.stringify(o)}\n\n`;
const delta = (content: string, finish: string | null = null) =>
sse({ id: `c${calls}`, object: "chat.completion.chunk", created: 0, model: "fake-model",
choices: [{ index: 0, delta: { content }, finish_reason: finish }] });
Bun.serve({
hostname: "127.0.0.1",
port: 18741,
async fetch(req) {
const path = new URL(req.url).pathname;
if (path.endsWith("/models"))
return Response.json({ object: "list", data: [{ id: "fake-model", object: "model" }] });
const fail = ++calls === 1;
const words = (fail ? "This partial answer is cut off by the provider" : "This is the retried answer.").split(/(?<= )/);
const body = new ReadableStream({
async start(c) {
const enc = (s: string) => c.enqueue(new TextEncoder().encode(s));
for (const w of words) { enc(delta(w)); await Bun.sleep(40); }
if (fail) enc(sse({ error: { message: "Internal server error", type: "server_error", code: "internal_error" } }));
else { enc(delta("", "stop")); enc("data: [DONE]\n\n"); }
c.close();
},
});
return new Response(body, { headers: { "content-type": "text/event-stream" } });
},
});Expected behavior
An ACP client can tell a retried model call apart from answer text and discard the abandoned attempt's output. Any of these would do:
- A structured retry signal instead of the
Info:text, for example asession/updateextension or_metaon the update. It would carry the attempt number and say that the previous attempt's output is void. - A
messageIdper model call onagent_message_chunkandagent_thought_chunk, so a client can drop the superseded message. The SDK already gives each attempt its ownmessageIdonassistant.message_start/assistant.message_deltaand emitsassistant.turn_retry, so the information exists inside the CLI. - When retries run out, a
session/prompterror, or at least a distinct stop reason, instead ofend_turnwith the failure written as answer text.
Additional context
The logs can't identify the failed request. At the default level each failure gets one line:
[WARNING] [rust:model_events::producer] Model provider request failed {"stage":"inference","api_endpoint":"ws:/responses","http_status":500,"failure_kind":"http_error"}
It has no request or call ID, no provider error code or message, and no attempt number. With --log-level debug the failed attempt adds only Upstream model error response shape (body content omitted) {...}. A Request-ID is logged only for the successful response ([rust:model_wire] response (Request-ID ...)), while every full wire request, including the system prompt and the user's text, is written to the log. events.jsonl keeps only the successful attempt. Please add the provider's request ID (and providerCallId / serviceRequestId, which the SDK's event schema already defines) and the provider's error code to that WARNING line.
Related: #2101 and #2267 (the transient error itself), and #1627, #2914 and #3562 (retries exhausted). Those are about the failures; this issue is about how they're surfaced over ACP.
Suggested labels: area:non-interactive, area:models.
Activity