github/copilot-sdk · error · InvalidOperationException
LLM inference response StartAsync() called twice.
Error message
LLM inference response StartAsync() called twice.
What it means
LlmInferenceExchange.StartResponseAsync writes the HTTP-style start-of-response (status/headers) exactly once per request. This InvalidOperationException guards against calling the response start twice, which would corrupt the single-response RPC protocol.
Solutions
- Guard response-start logic with a local started flag or only call StartAsync once per exchange
- Move retry logic so a NEW exchange is used for each attempt instead of reusing one
- Ensure error paths use ErrorResponseAsync rather than another StartAsync
Example fix
// before
await exchange.StartAsync(200, "OK", headers);
await exchange.StartAsync(200, "OK", headers); // throws
// after
if (!_responseStarted) { await exchange.StartAsync(200, "OK", headers); _responseStarted = true; } Defensive patterns
Strategy: type-guard
Validate before calling
if (_started) return; // already started; skip duplicate StartAsync
Type guard
bool CanStart(LlmInferenceExchange ex) => !ex.Started && !ex.Finished;
Try / catch
try { await exchange.StartAsync(200, "OK", headers); }
catch (InvalidOperationException ex) when (ex.Message.Contains("called twice")) { /* ignore duplicate start */ } Prevention
- Guard response start with a local flag
- Retry with a fresh exchange, never reuse one
- Use ErrorResponseAsync on error paths instead of re-starting
When it happens
Trigger: Calling StartAsync (or the code path that invokes StartResponseAsync) twice on the same LlmInferenceExchange — e.g. double invocation of a completion callback, or retry logic re-sending the response start on the same exchange.
Common situations: Middleware layered so both a handler and a fallback try to start the response; an error path starts the response and then a catch block starts it again; framework code retries StartAsync after a partial failure.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- LLM inference request was cancelled by the runtime.
- LLM inference response used after RPC connection closed.
- Request cancelled by runtime
- session.create response did not include a sessionId.
- Session ' ' is already tracked by this client.
AI-assisted analysis of github/copilot-sdk@cd8cf15dc3 (2026-09-09).
Data as JSON: /api/errors/80a3394e00f8fbca.
Report an issue: GitHub.
Appendix: source
Thrown at dotnet/src/CopilotRequestHandler.cs:758
}
if (item.Chunk is { Length: > 0 })
{
yield return item.Chunk;
}
}
}
}
// --- Response emit (driven by the handler). Strict state machine: ---
// StartResponseAsync once -> zero or more WriteResponseAsync -> exactly one
// of EndResponseAsync / ErrorResponseAsync.
internal async Task StartResponseAsync(int status, string? statusText, IReadOnlyDictionary<string, IReadOnlyList<string>>? headers)
{
if (_started)
{
throw new InvalidOperationException("LLM inference response StartAsync() called twice.");
}
if (_finished)
{
throw new InvalidOperationException("LLM inference response already finished.");
}
_started = true;
await ServerRpc()
.LlmInference.HttpResponseStartAsync(RequestId, status, ToWireHeaders(headers), statusText)
.ConfigureAwait(false);
}
internal Task WriteResponseAsync(ReadOnlyMemory<byte> data) =>
WriteChunkAsync(Convert.ToBase64String(data.ToArray()), binary: true);
internal Task WriteResponseAsync(string text)
{View on GitHub (pinned to cd8cf15dc3)