Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public AIAgentResponseExecutor(AIAgent agent)
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Create options with properties from the request
Expand All @@ -51,9 +52,14 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
};
var options = new ChatClientAgentRunOptions(chatOptions);

// Convert input to chat messages
// Convert input to chat messages, prepending conversation history if available
var messages = new List<ChatMessage>();

if (conversationHistory is not null)
{
messages.AddRange(conversationHistory);
}

foreach (var inputMessage in request.Input.GetInputMessages())
{
messages.Add(inputMessage.ToChatMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;

namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters;

/// <summary>
/// Converts stored <see cref="ItemResource"/> objects back to <see cref="ChatMessage"/> objects
/// for injecting conversation history into agent execution.
/// </summary>
internal static class ItemResourceConversions
{
/// <summary>
/// Converts a sequence of <see cref="ItemResource"/> items to a list of <see cref="ChatMessage"/> objects.
/// Only converts message, function call, and function result items. Other item types are skipped.
/// </summary>
public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items)
{
var messages = new List<ChatMessage>();

foreach (var item in items)
{
switch (item)
{
case ResponsesUserMessageItemResource userMsg:
messages.Add(new ChatMessage(ChatRole.User, ConvertContents(userMsg.Content)));
break;

case ResponsesAssistantMessageItemResource assistantMsg:
messages.Add(new ChatMessage(ChatRole.Assistant, ConvertContents(assistantMsg.Content)));
break;

case ResponsesSystemMessageItemResource systemMsg:
messages.Add(new ChatMessage(ChatRole.System, ConvertContents(systemMsg.Content)));
break;

case ResponsesDeveloperMessageItemResource developerMsg:
messages.Add(new ChatMessage(new ChatRole("developer"), ConvertContents(developerMsg.Content)));
break;

case FunctionToolCallItemResource funcCall:
var arguments = ParseArguments(funcCall.Arguments);
messages.Add(new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)
]));
break;

case FunctionToolCallOutputItemResource funcOutput:
messages.Add(new ChatMessage(ChatRole.Tool,
[
new FunctionResultContent(funcOutput.CallId, funcOutput.Output)
]));
break;

// Skip all other item types (reasoning, executor_action, web_search, etc.)
// They are not relevant for conversation context.
}
}

return messages;
}

private static List<AIContent> ConvertContents(List<ItemContent> contents)
{
var result = new List<AIContent>();
foreach (var content in contents)
{
var aiContent = ItemContentConverter.ToAIContent(content);
if (aiContent is not null)
{
result.Add(aiContent);
}
}

return result;
}

private static Dictionary<string, object?>? ParseArguments(string? argumentsJson)
{
if (string.IsNullOrEmpty(argumentsJson))
{
return null;
}

try
{
using var doc = JsonDocument.Parse(argumentsJson);
var result = new Dictionary<string, object?>();
foreach (var property in doc.RootElement.EnumerateObject())
{
result[property.Name] = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number => property.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => property.Value.GetRawText()
};
}

return result;
}
catch (JsonException)
{
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Ensure the agent is registered with '{agentName}' name in the dependency injecti
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string agentName = GetAgentName(request)!;
Expand All @@ -105,6 +106,11 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
var options = new ChatClientAgentRunOptions(chatOptions);
var messages = new List<ChatMessage>();

if (conversationHistory is not null)
{
messages.AddRange(conversationHistory);
}

foreach (var inputMessage in request.Input.GetInputMessages())
{
messages.Add(inputMessage.ToChatMessage());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;

namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;

Expand All @@ -28,10 +29,12 @@ internal interface IResponseExecutor
/// </summary>
/// <param name="context">The agent invocation context containing the ID generator and other context information.</param>
/// <param name="request">The create response request.</param>
/// <param name="conversationHistory">Optional prior conversation messages to prepend to the agent's input.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of streaming response events.</returns>
IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -425,11 +425,28 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state,
// Create agent invocation context
var context = new AgentInvocationContext(new IdGenerator(responseId: responseId, conversationId: state.Response?.Conversation?.Id));

// Load conversation history if a conversation ID is provided
IReadOnlyList<Extensions.AI.ChatMessage>? conversationHistory = null;
if (this._conversationStorage is not null && request.Conversation?.Id is not null)
{
var itemsResult = await this._conversationStorage.ListItemsAsync(
request.Conversation.Id,
limit: 100,
order: SortOrder.Ascending,
cancellationToken: linkedCts.Token).ConfigureAwait(false);

var history = ItemResourceConversions.ToChatMessages(itemsResult.Data);
if (history.Count > 0)
{
conversationHistory = history;
}
}

// Collect output items for conversation storage
List<ItemResource> outputItems = [];

// Execute using the injected executor
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, linkedCts.Token).ConfigureAwait(false))
await foreach (var streamingEvent in this._executor.ExecuteAsync(context, request, conversationHistory, linkedCts.Token).ConfigureAwait(false))
{
state.AddStreamingEvent(streamingEvent);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,75 @@ public async Task CreateResponseStreaming_WithConversationId_DoesNotForwardConve
Assert.Null(mockChatClient.LastChatOptions.ConversationId);
}

/// <summary>
/// Verifies that conversation history is passed to the agent on subsequent requests.
/// This test reproduces the bug described in GitHub issue #3484.
/// </summary>
[Fact]
public async Task CreateResponse_WithConversation_SecondRequestIncludesPriorMessagesAsync()
{
// Arrange
const string AgentName = "memory-agent";
const string Instructions = "You are a helpful assistant.";
const string AgentResponse = "Nice to meet you Alice";

var mockChatClient = new TestHelpers.ConversationMemoryMockChatClient(AgentResponse);
this._httpClient = await this.CreateTestServerWithCustomClientAndConversationsAsync(
AgentName, Instructions, mockChatClient);

// Create a conversation
string createConvJson = System.Text.Json.JsonSerializer.Serialize(
new { metadata = new { agent_id = AgentName } });
using StringContent createConvContent = new(createConvJson, Encoding.UTF8, "application/json");
HttpResponseMessage createConvResponse = await this._httpClient.PostAsync(
new Uri("/v1/conversations", UriKind.Relative), createConvContent);
Assert.True(createConvResponse.IsSuccessStatusCode);

string convJson = await createConvResponse.Content.ReadAsStringAsync();
using var convDoc = System.Text.Json.JsonDocument.Parse(convJson);
string conversationId = convDoc.RootElement.GetProperty("id").GetString()!;

// Act - First message
await this.SendRawResponseAsync(AgentName, "My name is Alice", conversationId, stream: false);

// Act - Second message in same conversation
await this.SendRawResponseAsync(AgentName, "What is my name?", conversationId, stream: false);

// Assert
Assert.Equal(2, mockChatClient.CallHistory.Count);

// First call: should have 1 message (just the user input)
Assert.Single(mockChatClient.CallHistory[0]);
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[0][0].Role);

// Second call: should have 3 messages (prior user + prior assistant + new user)
Assert.Equal(3, mockChatClient.CallHistory[1].Count);
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][0].Role);
Assert.Equal(ChatRole.Assistant, mockChatClient.CallHistory[1][1].Role);
Assert.Equal(ChatRole.User, mockChatClient.CallHistory[1][2].Role);
}

private async Task<HttpResponseMessage> SendRawResponseAsync(
string agentName, string input, string conversationId, bool stream)
{
var requestBody = new
{
input,
agent = new { name = agentName },
conversation = conversationId,
stream
};
string json = System.Text.Json.JsonSerializer.Serialize(requestBody);
using StringContent content = new(json, Encoding.UTF8, "application/json");
HttpResponseMessage response = await this._httpClient!.PostAsync(
new Uri($"/{agentName}/v1/responses", UriKind.Relative), content);
Assert.True(response.IsSuccessStatusCode, $"Response failed: {response.StatusCode}");

// Consume the full response body to ensure execution completes
await response.Content.ReadAsStringAsync();
return response;
}

private ResponsesClient CreateResponseClient(string agentName)
{
return new ResponsesClient(
Expand Down Expand Up @@ -1272,6 +1341,29 @@ private async Task<HttpClient> CreateTestServerWithConversationsAsync(string age
return testServer.CreateClient();
}

private async Task<HttpClient> CreateTestServerWithCustomClientAndConversationsAsync(string agentName, string instructions, IChatClient chatClient)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();

builder.Services.AddKeyedSingleton($"chat-client-{agentName}", chatClient);
builder.AddAIAgent(agentName, instructions, chatClientServiceKey: $"chat-client-{agentName}");
builder.AddOpenAIResponses();
builder.AddOpenAIConversations();

this._app = builder.Build();
AIAgent agent = this._app.Services.GetRequiredKeyedService<AIAgent>(agentName);
this._app.MapOpenAIResponses(agent);
this._app.MapOpenAIConversations();

await this._app.StartAsync();

TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");

return testServer.CreateClient();
}

private async Task<HttpClient> CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
Expand Down
Loading
Loading