← 输入框|github.com|刷新
Skip to content

ACP: a retried model call streams the failed attempt's partial answer, the retry notice as answer text, then the full answer #5266

Open

Description

@doomslayer2k

Describe the bug

In --acp mode, when a model call fails partway through an answer and the CLI retries it, the ACP client receives:

  1. the failed attempt's partial answer;
  2. Info: Request failed due to a transient API error. Retrying... as ordinary answer text;
  3. 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

  1. Run an OpenAI-compatible provider. Its first /chat/completions stream sends a few content chunks, then data: {"error":{"message":"Internal server error","type":"server_error","code":"internal_error"}}; its second call succeeds. A script is below.
  2. Start copilot --acp --stdio with COPILOT_PROVIDER_BASE_URL=http://127.0.0.1:18741/v1, COPILOT_MODEL=fake-model and COPILOT_OFFLINE=true.
  3. Send initialize and session/new, then session/prompt with any text.
  4. Watch the session/update notifications.

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 a session/update extension or _meta on the update. It would carry the attempt number and say that the previous attempt's output is void.
  • A messageId per model call on agent_message_chunk and agent_thought_chunk, so a client can drop the superseded message. The SDK already gives each attempt its own messageId on assistant.message_start / assistant.message_delta and emits assistant.turn_retry, so the information exists inside the CLI.
  • When retries run out, a session/prompt error, or at least a distinct stop reason, instead of end_turn with 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions