Skip to content

[Microsoft.Extensions.AI.OpenAI] Intermittent 400 previous_response_not_found when chaining Responses API turns with function invocation #7704

Description

@Licantrop0

Description

When using the OpenAI Responses API through AsIChatClient() in a multi-turn console app with function/tool invocation, requests intermittently fail with a 400 from the service:

Previous response with id 'resp_...' not found.

("code": "previous_response_not_found")

The failure is intermittent — the same conversation can run for several turns, then a turn fails. Once it fails, retrying the same user turn usually succeeds, which suggests a stale/never-persisted response id is being sent as previous_response_id rather than a permanently corrupt conversation state.

Microsoft.Extensions.AI.OpenAI is what maps ConversationId onto previous_response_id for the Responses client, so filing here rather than against openai/openai-dotnet.

Versions

Package Version
Microsoft.Extensions.AI 10.9.0
Microsoft.Extensions.AI.OpenAI 10.9.0
OpenAI 2.12.0
ModelContextProtocol 2.2.0

.NET 10, Windows. Endpoint is Azure OpenAI's OpenAI-compatible /openai/v1/ surface with BearerTokenPolicy.

Repro shape

var clientOptions = new OpenAIClientOptions
{
    Endpoint = new Uri($"{azureOpenAIEndpoint.TrimEnd('/')}/openai/v1/"),
    NetworkTimeout = TimeSpan.FromMinutes(5)
};

var openAIClient = new OpenAIClient(tokenPolicy, clientOptions);
var innerClient = openAIClient.GetResponsesClient().AsIChatClient();

var chatClient = new ChatClientBuilder(innerClient)
    .UseFunctionInvocation()
    .Build();

var chatOptions = new ChatOptions
{
    ModelId = model,
    AllowMultipleToolCalls = true,
    ToolMode = ChatToolMode.Auto,
    Tools = [.. mcpTools]   // tools obtained from MCP servers
};

IList<ChatMessage> chatHistory = [new ChatMessage(ChatRole.System, systemPrompt)];

// per user turn:
chatHistory.Add(new ChatMessage(ChatRole.User, userMessage));

List<ChatResponseUpdate> receivedUpdates = [];
await foreach (var update in chatClient.GetStreamingResponseAsync(chatHistory, chatOptions))
{
    receivedUpdates.Add(update);
    // ... render text / reasoning / function call + result content ...
}

chatHistory.AddMessages(receivedUpdates);

The app keeps a single IList<ChatMessage> history and a single shared ChatOptions instance across turns, appends streamed updates back into history via AddMessages, and never sets ChatOptions.ConversationId itself. Tools are MCP tools, so most turns include at least one function call round trip inside UseFunctionInvocation.

What I ruled out

The original app also had .UseDistributedCache(...) and .UseChatReducer(new SummarizingChatReducer(...)) in the pipeline, so my first assumption was that one of those was replaying or rewriting history in a way that resurrected a stale conversation id. That turned out not to be it:

  • Reproduces with and without UseDistributedCache.
  • Reproduces with and without UseChatReducer / SummarizingChatReducer.
  • Reproduces with local (stdio) MCP servers and with remote (HTTP) MCP servers, so it isn't specific to one transport or tool set.

With all of the optional middleware removed the failure still occurs, which is why I believe it lives in the Responses IChatClient response-chaining path (or in how ConversationId is round-tripped through streamed updates and AddMessages) rather than in application middleware.

Expected

Either:

  1. A conversation id that the service no longer recognizes should not be sent as previous_response_id — the client should fall back to sending the full input history instead of a dangling chain; or
  2. If chaining is expected to be the caller's responsibility here, it would help a lot to have documented guidance on when ConversationId is safe to carry across turns when UseFunctionInvocation performs intermediate round trips, and how a consumer is meant to detect and recover from previous_response_not_found.

Actual

An intermittent 400 previous_response_not_found surfaces to the caller as a ClientResultException, terminating the turn.

Notes

Happy to instrument and capture request payloads (previous_response_id vs. submitted input items) for the failing turn if that would help pin it down — let me know what would be most useful.

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