mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Add Azure AI Foundry Responses hosting adapter
Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework AIAgents and workflows within Azure Foundry as hosted agents via the Azure.AI.AgentServer.Responses SDK. - AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution - InputConverter: converts Responses API inputs/history to MEAI ChatMessage - OutputConverter: converts agent response updates to SSE event stream - ServiceCollectionExtensions: DI registration helpers - 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM) - ResponseStreamValidator: SSE protocol validation tool for samples - FoundryResponsesHosting sample app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -19,9 +19,13 @@
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-alpha.20260331.6" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-alpha.20260331.6" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-alpha.20260331.6" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.52.0" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.20.0" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
|
||||
<!-- Google Gemini -->
|
||||
|
||||
@@ -261,6 +261,9 @@
|
||||
<Project Path="samples/03-workflows/_StartHere/07_WriterCriticWorkflow/07_WriterCriticWorkflow.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/" />
|
||||
<Folder Name="/Samples/04-hosting/FoundryResponsesHosting/">
|
||||
<Project Path="samples/04-hosting/FoundryResponsesHosting/FoundryResponsesHosting.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/AzureFunctions/">
|
||||
<File Path="samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig" />
|
||||
@@ -491,6 +494,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureAIResponses/Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
@@ -537,6 +541,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests/Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
<add key="azure-sdk-for-net" value="https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="azure-sdk-for-net">
|
||||
<package pattern="Azure.AI.AgentServer.*" />
|
||||
</packageSource>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<NoWarn>$(NoWarn);NU1903;NU1605</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureAIResponses\Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,470 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/// <summary>
|
||||
/// Static HTML pages served by the sample application.
|
||||
/// </summary>
|
||||
internal static class Pages
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Homepage
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal const string Home = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foundry Responses Hosting — Demos</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 2rem; }
|
||||
main { width: 100%; max-width: 700px; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: .5rem; color: #1a1a1a; }
|
||||
.subtitle { color: #555; margin-bottom: 2rem; line-height: 1.5; }
|
||||
.cards { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.card { background: #fff; border: 1px solid #ddd; border-radius: 10px; padding: 1.5rem; text-decoration: none; color: inherit; transition: box-shadow .15s, transform .15s; }
|
||||
.card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.1); transform: translateY(-2px); }
|
||||
.card h2 { font-size: 1.15rem; color: #0066cc; margin-bottom: .4rem; }
|
||||
.card p { color: #555; line-height: 1.5; font-size: .9rem; }
|
||||
.card .tags { margin-top: .6rem; display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||
.card .tag { background: #e8f0fe; color: #1a73e8; padding: .15rem .5rem; border-radius: 12px; font-size: .75rem; }
|
||||
footer { margin-top: 2rem; font-size: .8rem; color: #999; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>🚀 Foundry Responses Hosting</h1>
|
||||
<p class="subtitle">
|
||||
Agent-framework agents hosted via the Azure AI Responses Server SDK.<br/>
|
||||
Each demo registers a different agent and serves it through <code>POST /responses</code>.
|
||||
</p>
|
||||
<div class="cards">
|
||||
<a class="card" href="/tool-demo">
|
||||
<h2>🔧 Tool Demo</h2>
|
||||
<p>An agent with local function tools (time, weather) and remote MCP tools from
|
||||
Microsoft Learn for documentation search.</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Local Tools</span>
|
||||
<span class="tag">MCP</span>
|
||||
<span class="tag">Microsoft Learn</span>
|
||||
<span class="tag">Streaming</span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="/workflow-demo">
|
||||
<h2>🔀 Workflow Demo</h2>
|
||||
<p>A triage workflow that routes questions to specialist agents — a Code Expert
|
||||
or a Creative Writer — using agent handoffs.</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Workflow</span>
|
||||
<span class="tag">Handoffs</span>
|
||||
<span class="tag">Multi-Agent</span>
|
||||
<span class="tag">Triage</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<footer>
|
||||
All demos share the same <code>/responses</code> endpoint.
|
||||
The <code>model</code> field in the request selects which agent handles it.
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Tool Demo
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal const string ToolDemo = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Tool Demo — Foundry Responses Hosting</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 2rem; }
|
||||
main { width: 100%; max-width: 800px; }
|
||||
h1 { font-size: 1.2rem; margin-bottom: .3rem; color: #333; }
|
||||
.subtitle { font-size: .85rem; color: #666; margin-bottom: .8rem; }
|
||||
a.back { font-size: .85rem; color: #0066cc; text-decoration: none; display: inline-block; margin-bottom: 1rem; }
|
||||
#chat { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; height: 56vh; overflow-y: auto; margin-bottom: 1rem; }
|
||||
.msg { margin-bottom: .75rem; line-height: 1.6; }
|
||||
.msg.user { color: #0066cc; }
|
||||
.msg.assistant { color: #333; }
|
||||
.msg .role { font-weight: 600; margin-right: .25rem; }
|
||||
.tool-call { background: #f0f4ff; border-left: 3px solid #4a90d9; padding: .4rem .6rem; margin: .4rem 0; border-radius: 4px; font-size: .85rem; color: #555; font-family: 'Cascadia Code', 'Fira Code', monospace; }
|
||||
.tool-call .tool-icon { margin-right: .3rem; }
|
||||
form { display: flex; gap: .5rem; }
|
||||
input { flex: 1; padding: .6rem .8rem; border: 1px solid #ccc; border-radius: 6px; font-size: 1rem; }
|
||||
button { padding: .6rem 1.2rem; background: #0066cc; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
#status { font-size: .85rem; color: #888; margin-top: .5rem; }
|
||||
.suggestions { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
|
||||
.suggestions button { padding: .3rem .7rem; font-size: .8rem; background: #e8f0fe; color: #1a73e8; border: 1px solid #c5d8f8; border-radius: 16px; cursor: pointer; }
|
||||
.suggestions button:hover { background: #d2e3fc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<a class="back" href="/">← Back to demos</a>
|
||||
<h1>🔧 Tool Demo</h1>
|
||||
<p class="subtitle">Agent with local tools (time, weather) + Microsoft Learn MCP (docs search)</p>
|
||||
<div class="suggestions">
|
||||
<button onclick="sendText('What time is it in Tokyo?')">🕐 Time in Tokyo</button>
|
||||
<button onclick="sendText('What is the weather in Seattle?')">🌤️ Weather in Seattle</button>
|
||||
<button onclick="sendText('How do I create an Azure Function using the CLI?')">📚 Azure Functions docs</button>
|
||||
<button onclick="sendText('What is Microsoft Agent Framework?')">📚 Agent Framework</button>
|
||||
</div>
|
||||
<div id="chat"></div>
|
||||
<form id="form">
|
||||
<input id="input" placeholder="Try: 'What time is it?' or 'Search docs for Azure AI Foundry'" autocomplete="off" autofocus />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<div id="status"></div>
|
||||
</main>
|
||||
<script src="/js/sse-validator.js"></script>
|
||||
<script>
|
||||
const AGENT = 'tool-agent';
|
||||
const chat = document.getElementById('chat');
|
||||
const form = document.getElementById('form');
|
||||
const input = document.getElementById('input');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
function escapeHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
function addMsg(role, html) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg ' + role; d.innerHTML = html;
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
|
||||
}
|
||||
|
||||
function addToolCall(name) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'tool-call';
|
||||
d.innerHTML = '<span class="tool-icon">🔧</span> Calling <b>' + escapeHtml(name) + '</b>…';
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
|
||||
}
|
||||
|
||||
function sendText(t) { input.value = t; form.dispatchEvent(new Event('submit')); }
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim(); if (!text) return;
|
||||
input.value = '';
|
||||
addMsg('user', '<span class="role">You:</span>' + escapeHtml(text));
|
||||
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true; status.textContent = 'Streaming…';
|
||||
|
||||
let fullText = '', assistantDiv = null;
|
||||
const toolCalls = {};
|
||||
const validator = new SseValidator();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/responses', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: AGENT, stream: true, input: text })
|
||||
});
|
||||
if (!resp.ok) { status.textContent = 'Error ' + resp.status; btn.disabled = false; return; }
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '', curEvt = null;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n'); buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) { curEvt = line.slice(7).trim(); continue; }
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const d = line.slice(6).trim(); if (d === '[DONE]') continue;
|
||||
try {
|
||||
const evt = JSON.parse(d);
|
||||
validator.capture(curEvt || evt.type || 'unknown', d);
|
||||
curEvt = null;
|
||||
if (evt.type === 'response.output_item.added' && evt.item?.type === 'function_call') {
|
||||
const id = evt.item.id;
|
||||
toolCalls[id] = { name: evt.item.name || '?', args: '', el: addToolCall(evt.item.name || '?') };
|
||||
status.textContent = 'Calling tool: ' + (evt.item.name || '…');
|
||||
}
|
||||
if (evt.type === 'response.function_call_arguments.delta' && evt.item_id && toolCalls[evt.item_id])
|
||||
toolCalls[evt.item_id].args += (evt.delta || '');
|
||||
if (evt.type === 'response.function_call_arguments.done' && evt.item_id && toolCalls[evt.item_id]) {
|
||||
const tc = toolCalls[evt.item_id];
|
||||
let args = tc.args; try { args = JSON.stringify(JSON.parse(args), null, 0); } catch {}
|
||||
tc.el.innerHTML = '<span class="tool-icon">✅</span> Called <b>' + escapeHtml(tc.name) + '</b>(' + escapeHtml(args) + ')';
|
||||
}
|
||||
if (evt.type === 'response.output_text.delta') {
|
||||
if (!assistantDiv) assistantDiv = addMsg('assistant', '<span class="role">Agent:</span>');
|
||||
fullText += evt.delta;
|
||||
assistantDiv.innerHTML = '<span class="role">Agent:</span>' + escapeHtml(fullText);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
status.textContent = 'Streaming…';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (!fullText && !assistantDiv) addMsg('assistant', '<span class="role">Agent:</span><em>(empty)</em>');
|
||||
status.textContent = '';
|
||||
} catch (err) { status.textContent = 'Error: ' + err.message; }
|
||||
if (validator.events.length > 0) {
|
||||
try { const vr = await validator.validate(); chat.appendChild(validator.renderElement(vr)); chat.scrollTop = chat.scrollHeight; } catch {}
|
||||
}
|
||||
btn.disabled = false; input.focus();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Workflow Demo
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal const string WorkflowDemo = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Workflow Demo — Foundry Responses Hosting</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 2rem; }
|
||||
main { width: 100%; max-width: 800px; }
|
||||
h1 { font-size: 1.2rem; margin-bottom: .3rem; color: #333; }
|
||||
.subtitle { font-size: .85rem; color: #666; margin-bottom: .8rem; }
|
||||
a.back { font-size: .85rem; color: #0066cc; text-decoration: none; display: inline-block; margin-bottom: 1rem; }
|
||||
#chat { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; height: 56vh; overflow-y: auto; margin-bottom: 1rem; }
|
||||
.msg { margin-bottom: .75rem; line-height: 1.6; }
|
||||
.msg.user { color: #0066cc; }
|
||||
.msg.assistant { color: #333; }
|
||||
.msg .role { font-weight: 600; margin-right: .25rem; }
|
||||
.workflow-evt { background: #f0f9f0; border-left: 3px solid #4caf50; padding: .4rem .6rem; margin: .4rem 0; border-radius: 4px; font-size: .85rem; color: #555; }
|
||||
.workflow-evt.failed { background: #fef0f0; border-left-color: #e53935; }
|
||||
.tool-call { background: #f0f4ff; border-left: 3px solid #4a90d9; padding: .4rem .6rem; margin: .4rem 0; border-radius: 4px; font-size: .85rem; color: #555; font-family: 'Cascadia Code', 'Fira Code', monospace; }
|
||||
form { display: flex; gap: .5rem; }
|
||||
input { flex: 1; padding: .6rem .8rem; border: 1px solid #ccc; border-radius: 6px; font-size: 1rem; }
|
||||
button { padding: .6rem 1.2rem; background: #0066cc; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
#status { font-size: .85rem; color: #888; margin-top: .5rem; }
|
||||
.suggestions { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
|
||||
.suggestions button { padding: .3rem .7rem; font-size: .8rem; background: #e8f0fe; color: #1a73e8; border: 1px solid #c5d8f8; border-radius: 16px; cursor: pointer; }
|
||||
.suggestions button:hover { background: #d2e3fc; }
|
||||
.agent-diagram { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; font-size: .85rem; text-align: center; color: #555; }
|
||||
.agent-diagram .flow { font-size: 1.1rem; letter-spacing: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<a class="back" href="/">← Back to demos</a>
|
||||
<h1>🔀 Workflow Demo — Agent Handoffs</h1>
|
||||
<p class="subtitle">A triage agent routes your question to a specialist (Code Expert or Creative Writer)</p>
|
||||
<div class="agent-diagram">
|
||||
<div class="flow">👤 User → 🔀 <b>Triage</b> → 💻 <b>Code Expert</b> / ✍️ <b>Creative Writer</b></div>
|
||||
</div>
|
||||
<div class="suggestions">
|
||||
<button onclick="sendText('Write a Python function to reverse a linked list')">💻 Reverse linked list</button>
|
||||
<button onclick="sendText('Write me a haiku about cloud computing')">✍️ Cloud haiku</button>
|
||||
<button onclick="sendText('Explain the difference between async and threads in C#')">💻 Async vs threads</button>
|
||||
<button onclick="sendText('Write a short story about an AI that learns to paint')">✍️ AI painter story</button>
|
||||
</div>
|
||||
<div id="chat"></div>
|
||||
<form id="form">
|
||||
<input id="input" placeholder="Ask a coding question or request creative writing…" autocomplete="off" autofocus />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<div id="status"></div>
|
||||
</main>
|
||||
<script src="/js/sse-validator.js"></script>
|
||||
<script>
|
||||
const AGENT = 'triage-workflow';
|
||||
const chat = document.getElementById('chat');
|
||||
const form = document.getElementById('form');
|
||||
const input = document.getElementById('input');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
function escapeHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
function addMsg(role, html) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg ' + role; d.innerHTML = html;
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
|
||||
}
|
||||
|
||||
function addWorkflowEvent(icon, text, failed) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'workflow-evt' + (failed ? ' failed' : '');
|
||||
d.innerHTML = icon + ' ' + escapeHtml(text);
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
|
||||
function addToolCall(name) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'tool-call';
|
||||
d.innerHTML = '🔀 Handoff: <b>' + escapeHtml(name) + '</b>';
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
|
||||
}
|
||||
|
||||
function sendText(t) { input.value = t; form.dispatchEvent(new Event('submit')); }
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim(); if (!text) return;
|
||||
input.value = '';
|
||||
addMsg('user', '<span class="role">You:</span>' + escapeHtml(text));
|
||||
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true; status.textContent = 'Running workflow…';
|
||||
|
||||
let fullText = '', assistantDiv = null;
|
||||
const toolCalls = {};
|
||||
const validator = new SseValidator();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/responses', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: AGENT, stream: true, input: text })
|
||||
});
|
||||
if (!resp.ok) { status.textContent = 'Error ' + resp.status; btn.disabled = false; return; }
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '', curEvt = null;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n'); buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) { curEvt = line.slice(7).trim(); continue; }
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const d = line.slice(6).trim(); if (d === '[DONE]') continue;
|
||||
try {
|
||||
const evt = JSON.parse(d);
|
||||
validator.capture(curEvt || evt.type || 'unknown', d);
|
||||
curEvt = null;
|
||||
|
||||
// Workflow events (executor invoked/completed/failed)
|
||||
if (evt.type === 'response.output_item.added' && evt.item?.type === 'workflow_action') {
|
||||
const s = evt.item.status;
|
||||
const id = evt.item.action_id || evt.item.actionId || '?';
|
||||
if (s === 'in_progress' || s === 'InProgress')
|
||||
addWorkflowEvent('▶️', 'Agent invoked: ' + id);
|
||||
else if (s === 'completed' || s === 'Completed')
|
||||
addWorkflowEvent('✅', 'Agent completed: ' + id);
|
||||
else if (s === 'failed' || s === 'Failed')
|
||||
addWorkflowEvent('❌', 'Agent failed: ' + id, true);
|
||||
}
|
||||
|
||||
// Handoff function calls
|
||||
if (evt.type === 'response.output_item.added' && evt.item?.type === 'function_call') {
|
||||
const id = evt.item.id;
|
||||
toolCalls[id] = { name: evt.item.name || '?', args: '', el: addToolCall(evt.item.name || '?') };
|
||||
status.textContent = 'Handoff: ' + (evt.item.name || '…');
|
||||
}
|
||||
if (evt.type === 'response.function_call_arguments.delta' && evt.item_id && toolCalls[evt.item_id])
|
||||
toolCalls[evt.item_id].args += (evt.delta || '');
|
||||
if (evt.type === 'response.function_call_arguments.done' && evt.item_id && toolCalls[evt.item_id]) {
|
||||
const tc = toolCalls[evt.item_id];
|
||||
let args = tc.args; try { args = JSON.stringify(JSON.parse(args), null, 0); } catch {}
|
||||
tc.el.innerHTML = '🔀 Handoff: <b>' + escapeHtml(tc.name) + '</b>(' + escapeHtml(args) + ')';
|
||||
}
|
||||
|
||||
// Text streaming from the specialist agent
|
||||
if (evt.type === 'response.output_text.delta') {
|
||||
if (!assistantDiv) assistantDiv = addMsg('assistant', '<span class="role">Agent:</span>');
|
||||
fullText += evt.delta;
|
||||
assistantDiv.innerHTML = '<span class="role">Agent:</span>' + escapeHtml(fullText);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
status.textContent = 'Streaming…';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (!fullText && !assistantDiv) addMsg('assistant', '<span class="role">Agent:</span><em>(empty)</em>');
|
||||
status.textContent = '';
|
||||
} catch (err) { status.textContent = 'Error: ' + err.message; }
|
||||
if (validator.events.length > 0) {
|
||||
try { const vr = await validator.validate(); chat.appendChild(validator.renderElement(vr)); chat.scrollTop = chat.scrollHeight; } catch {}
|
||||
}
|
||||
btn.disabled = false; input.focus();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SSE Validator Script (shared by all demo pages)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal const string ValidationScript = """
|
||||
// SseValidator - inline SSE stream validation for Foundry Responses demos
|
||||
// Captures events during streaming and validates against the API behaviour contract.
|
||||
(function() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.sse-val { margin: .4rem 0 .6rem; padding: .3rem .5rem; font-size: .75rem; color: #aaa; border-top: 1px dashed #e8e8e8; }
|
||||
.val-ok { color: #7ab88a; }
|
||||
.val-err { color: #d47272; font-weight: 500; }
|
||||
.val-issues { margin: .2rem 0; }
|
||||
.val-issue { color: #c06060; font-size: .72rem; padding: .1rem 0; }
|
||||
.val-issue b { color: #b04040; }
|
||||
.val-at { color: #ccc; font-size: .68rem; }
|
||||
.val-log summary { cursor: pointer; color: #bbb; font-size: .72rem; }
|
||||
.val-log-items { max-height: 120px; overflow-y: auto; font-size: .7rem; background: #fafafa;
|
||||
padding: .3rem; border-radius: 3px; margin-top: .15rem;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace; }
|
||||
.val-i { color: #ccc; display: inline-block; width: 1.8rem; text-align: right; margin-right: .3rem; }
|
||||
.val-t { color: #8ab4d0; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
|
||||
class SseValidator {
|
||||
constructor() { this.events = []; }
|
||||
reset() { this.events = []; }
|
||||
capture(eventType, data) { this.events.push({ eventType, data }); }
|
||||
|
||||
async validate() {
|
||||
const resp = await fetch('/api/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ events: this.events })
|
||||
});
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
renderElement(result) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sse-val';
|
||||
const n = result.eventCount;
|
||||
const ok = result.isValid;
|
||||
const vs = result.violations || [];
|
||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
let h = ok
|
||||
? `<span class="val-ok">${n} events — all rules passed ✅</span>`
|
||||
: `<span class="val-err">${n} events — ${vs.length} violation(s)</span>`;
|
||||
|
||||
if (vs.length) {
|
||||
h += '<div class="val-issues">';
|
||||
vs.forEach(v => {
|
||||
h += `<div class="val-issue"><b>[${esc(v.ruleId)}]</b> ${esc(v.message)} <span class="val-at">#${v.eventIndex}</span></div>`;
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
h += `<details class="val-log"><summary>Event log (${this.events.length})</summary><div class="val-log-items">`;
|
||||
this.events.forEach((e, i) => {
|
||||
h += `<div><span class="val-i">${i}</span> <span class="val-t">${esc(e.eventType)}</span></div>`;
|
||||
});
|
||||
h += '</div></details>';
|
||||
|
||||
el.innerHTML = h;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates hosting agent-framework agents as Foundry Hosted Agents
|
||||
// using the Azure AI Responses Server SDK.
|
||||
//
|
||||
// Demos:
|
||||
// / - Homepage listing all demos
|
||||
// /tool-demo - Agent with local tools + remote MCP tools
|
||||
// /workflow-demo - Triage workflow routing to specialist agents
|
||||
//
|
||||
// Prerequisites:
|
||||
// - Azure OpenAI resource with a deployed model
|
||||
//
|
||||
// Environment variables:
|
||||
// - AZURE_OPENAI_ENDPOINT - your Azure OpenAI endpoint
|
||||
// - AZURE_OPENAI_DEPLOYMENT - the model deployment name (default: "gpt-4o")
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.AzureAIResponses;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Register the Azure AI Responses Server SDK
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddResponsesServer();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Create the shared Azure OpenAI chat client
|
||||
// ---------------------------------------------------------------------------
|
||||
var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."));
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o";
|
||||
|
||||
var azureClient = new AzureOpenAIClient(endpoint, new DefaultAzureCredential());
|
||||
IChatClient chatClient = azureClient.GetChatClient(deployment).AsIChatClient();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. DEMO 1: Tool Agent — local tools + Microsoft Learn MCP
|
||||
// ---------------------------------------------------------------------------
|
||||
Console.WriteLine("Connecting to Microsoft Learn MCP server...");
|
||||
McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri("https://learn.microsoft.com/api/mcp"),
|
||||
Name = "Microsoft Learn MCP",
|
||||
}));
|
||||
var mcpTools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
|
||||
|
||||
builder.AddAIAgent(
|
||||
name: "tool-agent",
|
||||
instructions: """
|
||||
You are a helpful assistant hosted as a Foundry Hosted Agent.
|
||||
You have access to several tools - use them proactively:
|
||||
- GetCurrentTime: Returns the current date/time in any timezone.
|
||||
- GetWeather: Returns weather conditions for any location.
|
||||
- Microsoft Learn MCP tools: Search and fetch Microsoft documentation.
|
||||
When a user asks a technical question about Microsoft products, use the
|
||||
documentation search tools to give accurate, up-to-date answers.
|
||||
""",
|
||||
chatClient: chatClient)
|
||||
.WithAITool(AIFunctionFactory.Create(GetCurrentTime))
|
||||
.WithAITool(AIFunctionFactory.Create(GetWeather))
|
||||
.WithAITools(mcpTools.Cast<AITool>().ToArray());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. DEMO 2: Triage Workflow — routes to specialist agents
|
||||
// ---------------------------------------------------------------------------
|
||||
ChatClientAgent triageAgent = new(
|
||||
chatClient,
|
||||
instructions: """
|
||||
You are a triage agent that determines which specialist to hand off to.
|
||||
Based on the user's question, ALWAYS hand off to one of the available agents.
|
||||
Do NOT answer the question yourself - just route it.
|
||||
""",
|
||||
name: "triage_agent",
|
||||
description: "Routes messages to the appropriate specialist agent");
|
||||
|
||||
ChatClientAgent codeExpert = new(
|
||||
chatClient,
|
||||
instructions: """
|
||||
You are a coding and technology expert. You help with programming questions,
|
||||
explain technical concepts, debug code, and suggest best practices.
|
||||
Provide clear, well-structured answers with code examples when appropriate.
|
||||
""",
|
||||
name: "code_expert",
|
||||
description: "Specialist agent for programming and technology questions");
|
||||
|
||||
ChatClientAgent creativeWriter = new(
|
||||
chatClient,
|
||||
instructions: """
|
||||
You are a creative writing specialist. You help write stories, poems,
|
||||
marketing copy, emails, and other creative content. You have a flair
|
||||
for engaging language and vivid descriptions.
|
||||
""",
|
||||
name: "creative_writer",
|
||||
description: "Specialist agent for creative writing and content tasks");
|
||||
|
||||
Workflow triageWorkflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triageAgent)
|
||||
.WithHandoffs(triageAgent, [codeExpert, creativeWriter])
|
||||
.WithHandoffs([codeExpert, creativeWriter], triageAgent)
|
||||
.Build();
|
||||
|
||||
builder.AddAIAgent("triage-workflow", (_, key) =>
|
||||
triageWorkflow.AsAIAgent(name: key));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Wire up the agent-framework handler as the IResponseHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddAgentFrameworkHandler();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Dispose the MCP client on shutdown
|
||||
app.Lifetime.ApplicationStopping.Register(() =>
|
||||
mcpClient.DisposeAsync().AsTask().GetAwaiter().GetResult());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
app.MapGet("/ready", () => Results.Ok("ready"));
|
||||
app.MapResponsesServer();
|
||||
|
||||
app.MapGet("/", () => Results.Content(Pages.Home, "text/html"));
|
||||
app.MapGet("/tool-demo", () => Results.Content(Pages.ToolDemo, "text/html"));
|
||||
app.MapGet("/workflow-demo", () => Results.Content(Pages.WorkflowDemo, "text/html"));
|
||||
app.MapGet("/js/sse-validator.js", () => Results.Content(Pages.ValidationScript, "application/javascript"));
|
||||
|
||||
// Validation endpoint: accepts captured SSE lines and validates them
|
||||
app.MapPost("/api/validate", (FoundryResponsesHosting.CapturedSseStream captured) =>
|
||||
{
|
||||
var validator = new FoundryResponsesHosting.ResponseStreamValidator();
|
||||
foreach (var evt in captured.Events)
|
||||
{
|
||||
validator.ProcessEvent(evt.EventType, evt.Data);
|
||||
}
|
||||
|
||||
validator.Complete();
|
||||
return Results.Json(validator.GetResult());
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local tool definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Description("Gets the current date and time in the specified timezone.")]
|
||||
static string GetCurrentTime(
|
||||
[Description("IANA timezone (e.g. 'America/New_York', 'Europe/London', 'UTC'). Defaults to UTC.")]
|
||||
string timezone = "UTC")
|
||||
{
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(timezone);
|
||||
return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).ToString("F");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DateTime.UtcNow.ToString("F") + " (UTC - unknown timezone: " + timezone + ")";
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Gets the current weather for a location. Returns temperature, conditions, and humidity.")]
|
||||
static string GetWeather(
|
||||
[Description("The city or location (e.g. 'Seattle', 'London, UK').")]
|
||||
string location)
|
||||
{
|
||||
// Simulated weather - deterministic per location for demo consistency
|
||||
var rng = new Random(location.ToUpperInvariant().GetHashCode());
|
||||
var temp = rng.Next(-5, 35);
|
||||
string[] conditions = ["sunny", "partly cloudy", "overcast", "rainy", "snowy", "windy", "foggy"];
|
||||
var condition = conditions[rng.Next(conditions.Length)];
|
||||
return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h.";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"FoundryResponsesHosting": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:54747;http://localhost:54748"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FoundryResponsesHosting;
|
||||
|
||||
/// <summary>Captured SSE event for validation.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")]
|
||||
internal sealed record CapturedSseEvent(
|
||||
[property: JsonPropertyName("eventType")] string EventType,
|
||||
[property: JsonPropertyName("data")] string Data);
|
||||
|
||||
/// <summary>Captured SSE stream sent from the client for server-side validation.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")]
|
||||
internal sealed record CapturedSseStream(
|
||||
[property: JsonPropertyName("events")] List<CapturedSseEvent> Events);
|
||||
|
||||
/// <summary>
|
||||
/// Validates an SSE event stream from the Azure AI Responses Server SDK against
|
||||
/// the API behaviour contract. Feed events sequentially via <see cref="ProcessEvent"/>
|
||||
/// and call <see cref="Complete"/> when the stream ends.
|
||||
/// </summary>
|
||||
internal sealed class ResponseStreamValidator
|
||||
{
|
||||
private readonly List<ValidationViolation> _violations = [];
|
||||
private int _eventCount;
|
||||
private int _expectedSequenceNumber;
|
||||
private StreamState _state = StreamState.Initial;
|
||||
private string? _responseId;
|
||||
private readonly HashSet<int> _addedItemIndices = [];
|
||||
private readonly HashSet<int> _doneItemIndices = [];
|
||||
private readonly HashSet<string> _addedContentParts = []; // "outputIdx:partIdx"
|
||||
private readonly HashSet<string> _doneContentParts = [];
|
||||
private readonly Dictionary<string, string> _textAccumulators = []; // "outputIdx:contentIdx" → accumulated text
|
||||
private bool _hasTerminal;
|
||||
|
||||
/// <summary>All violations found so far.</summary>
|
||||
internal IReadOnlyList<ValidationViolation> Violations => _violations;
|
||||
|
||||
/// <summary>
|
||||
/// Processes a single SSE event line pair (event type + JSON data).
|
||||
/// </summary>
|
||||
/// <param name="eventType">The SSE event type (e.g. "response.created").</param>
|
||||
/// <param name="jsonData">The raw JSON data payload.</param>
|
||||
internal void ProcessEvent(string eventType, string jsonData)
|
||||
{
|
||||
JsonElement data;
|
||||
try
|
||||
{
|
||||
data = JsonDocument.Parse(jsonData).RootElement;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Fail("PARSE-01", $"Invalid JSON in event data: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_eventCount++;
|
||||
|
||||
// ── Sequence number validation ──────────────────────────────────
|
||||
if (data.TryGetProperty("sequence_number", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
int seq = seqProp.GetInt32();
|
||||
if (seq != _expectedSequenceNumber)
|
||||
{
|
||||
Fail("SEQ-01", $"Expected sequence_number {_expectedSequenceNumber}, got {seq}");
|
||||
}
|
||||
|
||||
_expectedSequenceNumber = seq + 1;
|
||||
}
|
||||
else if (_state != StreamState.Initial || eventType != "error")
|
||||
{
|
||||
// Pre-creation error events may not have sequence_number
|
||||
Fail("SEQ-02", $"Missing sequence_number on event '{eventType}'");
|
||||
}
|
||||
|
||||
// ── Post-terminal guard ─────────────────────────────────────────
|
||||
if (_hasTerminal)
|
||||
{
|
||||
Fail("TERM-01", $"Event '{eventType}' received after terminal event");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Dispatch by event type ──────────────────────────────────────
|
||||
switch (eventType)
|
||||
{
|
||||
case "response.created":
|
||||
ValidateResponseCreated(data);
|
||||
break;
|
||||
|
||||
case "response.queued":
|
||||
ValidateStateTransition(eventType, StreamState.Created, StreamState.Queued);
|
||||
ValidateResponseEnvelope(data, eventType);
|
||||
break;
|
||||
|
||||
case "response.in_progress":
|
||||
if (_state is StreamState.Created or StreamState.Queued)
|
||||
{
|
||||
_state = StreamState.InProgress;
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail("ORDER-02", $"'response.in_progress' received in state {_state} (expected Created or Queued)");
|
||||
}
|
||||
|
||||
ValidateResponseEnvelope(data, eventType);
|
||||
break;
|
||||
|
||||
case "response.output_item.added":
|
||||
case "output_item.added":
|
||||
ValidateInProgress(eventType);
|
||||
ValidateOutputItemAdded(data);
|
||||
break;
|
||||
|
||||
case "response.output_item.done":
|
||||
case "output_item.done":
|
||||
ValidateInProgress(eventType);
|
||||
ValidateOutputItemDone(data);
|
||||
break;
|
||||
|
||||
case "response.content_part.added":
|
||||
case "content_part.added":
|
||||
ValidateInProgress(eventType);
|
||||
ValidateContentPartAdded(data);
|
||||
break;
|
||||
|
||||
case "response.content_part.done":
|
||||
case "content_part.done":
|
||||
ValidateInProgress(eventType);
|
||||
ValidateContentPartDone(data);
|
||||
break;
|
||||
|
||||
case "response.output_text.delta":
|
||||
case "output_text.delta":
|
||||
ValidateInProgress(eventType);
|
||||
ValidateTextDelta(data);
|
||||
break;
|
||||
|
||||
case "response.output_text.done":
|
||||
case "output_text.done":
|
||||
ValidateInProgress(eventType);
|
||||
ValidateTextDone(data);
|
||||
break;
|
||||
|
||||
case "response.function_call_arguments.delta":
|
||||
case "function_call_arguments.delta":
|
||||
ValidateInProgress(eventType);
|
||||
break;
|
||||
|
||||
case "response.function_call_arguments.done":
|
||||
case "function_call_arguments.done":
|
||||
ValidateInProgress(eventType);
|
||||
break;
|
||||
|
||||
case "response.completed":
|
||||
ValidateTerminal(data, "completed");
|
||||
break;
|
||||
|
||||
case "response.failed":
|
||||
ValidateTerminal(data, "failed");
|
||||
break;
|
||||
|
||||
case "response.incomplete":
|
||||
ValidateTerminal(data, "incomplete");
|
||||
break;
|
||||
|
||||
case "error":
|
||||
// Pre-creation error — standalone, no response.created precedes it
|
||||
if (_state != StreamState.Initial)
|
||||
{
|
||||
Fail("ERR-01", "'error' event received after response.created — should use response.failed instead");
|
||||
}
|
||||
|
||||
_hasTerminal = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown events are not violations — the spec may evolve
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call after the stream ends. Checks that a terminal event was received.
|
||||
/// </summary>
|
||||
internal void Complete()
|
||||
{
|
||||
if (!_hasTerminal && _state != StreamState.Initial)
|
||||
{
|
||||
Fail("TERM-02", "Stream ended without a terminal event (response.completed, response.failed, or response.incomplete)");
|
||||
}
|
||||
|
||||
if (_state == StreamState.Initial && _eventCount == 0)
|
||||
{
|
||||
Fail("EMPTY-01", "No events received in the stream");
|
||||
}
|
||||
|
||||
// Check for output items that were added but never completed
|
||||
foreach (int idx in _addedItemIndices)
|
||||
{
|
||||
if (!_doneItemIndices.Contains(idx))
|
||||
{
|
||||
Fail("ITEM-03", $"Output item at index {idx} was added but never received output_item.done");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for content parts that were added but never completed
|
||||
foreach (string key in _addedContentParts)
|
||||
{
|
||||
if (!_doneContentParts.Contains(key))
|
||||
{
|
||||
Fail("CONTENT-03", $"Content part '{key}' was added but never received content_part.done");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a summary of all validation results.
|
||||
/// </summary>
|
||||
internal ValidationResult GetResult()
|
||||
{
|
||||
return new ValidationResult(
|
||||
EventCount: _eventCount,
|
||||
IsValid: _violations.Count == 0,
|
||||
Violations: [.. _violations]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Event-specific validators
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private void ValidateResponseCreated(JsonElement data)
|
||||
{
|
||||
if (_state != StreamState.Initial)
|
||||
{
|
||||
Fail("ORDER-01", $"'response.created' received in state {_state} (expected Initial — must be first event)");
|
||||
return;
|
||||
}
|
||||
|
||||
_state = StreamState.Created;
|
||||
|
||||
// Must have a response envelope
|
||||
if (!data.TryGetProperty("response", out var resp))
|
||||
{
|
||||
Fail("FIELD-01", "'response.created' missing 'response' object");
|
||||
return;
|
||||
}
|
||||
|
||||
// Required response fields
|
||||
ValidateRequiredResponseFields(resp, "response.created");
|
||||
|
||||
// Capture response ID for cross-event checks
|
||||
if (resp.TryGetProperty("id", out var idProp))
|
||||
{
|
||||
_responseId = idProp.GetString();
|
||||
}
|
||||
|
||||
// Status must be non-terminal
|
||||
if (resp.TryGetProperty("status", out var statusProp))
|
||||
{
|
||||
string? status = statusProp.GetString();
|
||||
if (status is "completed" or "failed" or "incomplete" or "cancelled")
|
||||
{
|
||||
Fail("STATUS-01", $"'response.created' has terminal status '{status}' — must be 'queued' or 'in_progress'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTerminal(JsonElement data, string expectedKind)
|
||||
{
|
||||
if (_state is StreamState.Initial or StreamState.Created)
|
||||
{
|
||||
Fail("ORDER-03", $"Terminal event 'response.{expectedKind}' received before 'response.in_progress'");
|
||||
}
|
||||
|
||||
_hasTerminal = true;
|
||||
_state = StreamState.Terminal;
|
||||
|
||||
if (!data.TryGetProperty("response", out var resp))
|
||||
{
|
||||
Fail("FIELD-01", $"'response.{expectedKind}' missing 'response' object");
|
||||
return;
|
||||
}
|
||||
|
||||
ValidateRequiredResponseFields(resp, $"response.{expectedKind}");
|
||||
|
||||
if (resp.TryGetProperty("status", out var statusProp))
|
||||
{
|
||||
string? status = statusProp.GetString();
|
||||
|
||||
// completed_at validation (B6)
|
||||
bool hasCompletedAt = resp.TryGetProperty("completed_at", out var catProp)
|
||||
&& catProp.ValueKind != JsonValueKind.Null;
|
||||
|
||||
if (status == "completed" && !hasCompletedAt)
|
||||
{
|
||||
Fail("FIELD-02", "'completed_at' must be non-null when status is 'completed'");
|
||||
}
|
||||
|
||||
if (status != "completed" && hasCompletedAt)
|
||||
{
|
||||
Fail("FIELD-03", $"'completed_at' must be null when status is '{status}'");
|
||||
}
|
||||
|
||||
// error field validation
|
||||
bool hasError = resp.TryGetProperty("error", out var errProp)
|
||||
&& errProp.ValueKind != JsonValueKind.Null;
|
||||
|
||||
if (status == "failed" && !hasError)
|
||||
{
|
||||
Fail("FIELD-04", "'error' must be non-null when status is 'failed'");
|
||||
}
|
||||
|
||||
if (status is "completed" or "incomplete" && hasError)
|
||||
{
|
||||
Fail("FIELD-05", $"'error' must be null when status is '{status}'");
|
||||
}
|
||||
|
||||
// error structure validation
|
||||
if (hasError)
|
||||
{
|
||||
ValidateErrorObject(errProp, $"response.{expectedKind}");
|
||||
}
|
||||
|
||||
// cancelled output must be empty (B11)
|
||||
if (status == "cancelled" && resp.TryGetProperty("output", out var outputProp)
|
||||
&& outputProp.ValueKind == JsonValueKind.Array && outputProp.GetArrayLength() > 0)
|
||||
{
|
||||
Fail("CANCEL-01", "Cancelled response must have empty output array (B11)");
|
||||
}
|
||||
|
||||
// response ID consistency
|
||||
if (_responseId is not null && resp.TryGetProperty("id", out var idProp)
|
||||
&& idProp.GetString() != _responseId)
|
||||
{
|
||||
Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'");
|
||||
}
|
||||
}
|
||||
|
||||
// Usage validation (optional, but if present must be structured correctly)
|
||||
if (resp.TryGetProperty("usage", out var usageProp) && usageProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
ValidateUsage(usageProp, $"response.{expectedKind}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateOutputItemAdded(JsonElement data)
|
||||
{
|
||||
if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
int index = idxProp.GetInt32();
|
||||
if (!_addedItemIndices.Add(index))
|
||||
{
|
||||
Fail("ITEM-01", $"Duplicate output_item.added for output_index {index}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail("FIELD-06", "output_item.added missing 'output_index' field");
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("item", out _))
|
||||
{
|
||||
Fail("FIELD-07", "output_item.added missing 'item' object");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateOutputItemDone(JsonElement data)
|
||||
{
|
||||
if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
int index = idxProp.GetInt32();
|
||||
if (!_addedItemIndices.Contains(index))
|
||||
{
|
||||
Fail("ITEM-02", $"output_item.done for output_index {index} without preceding output_item.added");
|
||||
}
|
||||
|
||||
_doneItemIndices.Add(index);
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail("FIELD-06", "output_item.done missing 'output_index' field");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateContentPartAdded(JsonElement data)
|
||||
{
|
||||
string key = GetContentPartKey(data);
|
||||
if (!_addedContentParts.Add(key))
|
||||
{
|
||||
Fail("CONTENT-01", $"Duplicate content_part.added for {key}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateContentPartDone(JsonElement data)
|
||||
{
|
||||
string key = GetContentPartKey(data);
|
||||
if (!_addedContentParts.Contains(key))
|
||||
{
|
||||
Fail("CONTENT-02", $"content_part.done for {key} without preceding content_part.added");
|
||||
}
|
||||
|
||||
_doneContentParts.Add(key);
|
||||
}
|
||||
|
||||
private void ValidateTextDelta(JsonElement data)
|
||||
{
|
||||
string key = GetTextKey(data);
|
||||
string delta = data.TryGetProperty("delta", out var deltaProp)
|
||||
? deltaProp.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
if (!_textAccumulators.TryGetValue(key, out string? existing))
|
||||
{
|
||||
_textAccumulators[key] = delta;
|
||||
}
|
||||
else
|
||||
{
|
||||
_textAccumulators[key] = existing + delta;
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTextDone(JsonElement data)
|
||||
{
|
||||
string key = GetTextKey(data);
|
||||
string? finalText = data.TryGetProperty("text", out var textProp)
|
||||
? textProp.GetString()
|
||||
: null;
|
||||
|
||||
if (finalText is null)
|
||||
{
|
||||
Fail("TEXT-01", $"output_text.done for {key} missing 'text' field");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_textAccumulators.TryGetValue(key, out string? accumulated) && accumulated != finalText)
|
||||
{
|
||||
Fail("TEXT-02", $"output_text.done text for {key} does not match accumulated deltas (accumulated {accumulated.Length} chars, done has {finalText.Length} chars)");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Shared field validators
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private void ValidateRequiredResponseFields(JsonElement resp, string context)
|
||||
{
|
||||
if (!HasNonNullString(resp, "id"))
|
||||
{
|
||||
Fail("FIELD-01", $"{context}: response missing 'id'");
|
||||
}
|
||||
|
||||
if (resp.TryGetProperty("object", out var objProp))
|
||||
{
|
||||
if (objProp.GetString() != "response")
|
||||
{
|
||||
Fail("FIELD-08", $"{context}: response.object must be 'response', got '{objProp.GetString()}'");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail("FIELD-08", $"{context}: response missing 'object' field");
|
||||
}
|
||||
|
||||
if (!resp.TryGetProperty("created_at", out var catProp) || catProp.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
Fail("FIELD-09", $"{context}: response missing 'created_at'");
|
||||
}
|
||||
|
||||
if (!resp.TryGetProperty("status", out _))
|
||||
{
|
||||
Fail("FIELD-10", $"{context}: response missing 'status'");
|
||||
}
|
||||
|
||||
if (!resp.TryGetProperty("output", out var outputProp) || outputProp.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
Fail("FIELD-11", $"{context}: response missing 'output' array");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateErrorObject(JsonElement error, string context)
|
||||
{
|
||||
if (!HasNonNullString(error, "code"))
|
||||
{
|
||||
Fail("ERR-02", $"{context}: error object missing 'code' field");
|
||||
}
|
||||
|
||||
if (!HasNonNullString(error, "message"))
|
||||
{
|
||||
Fail("ERR-03", $"{context}: error object missing 'message' field");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateUsage(JsonElement usage, string context)
|
||||
{
|
||||
if (!usage.TryGetProperty("input_tokens", out _))
|
||||
{
|
||||
Fail("USAGE-01", $"{context}: usage missing 'input_tokens'");
|
||||
}
|
||||
|
||||
if (!usage.TryGetProperty("output_tokens", out _))
|
||||
{
|
||||
Fail("USAGE-02", $"{context}: usage missing 'output_tokens'");
|
||||
}
|
||||
|
||||
if (!usage.TryGetProperty("total_tokens", out _))
|
||||
{
|
||||
Fail("USAGE-03", $"{context}: usage missing 'total_tokens'");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateResponseEnvelope(JsonElement data, string eventType)
|
||||
{
|
||||
if (!data.TryGetProperty("response", out var resp))
|
||||
{
|
||||
Fail("FIELD-01", $"'{eventType}' missing 'response' object");
|
||||
return;
|
||||
}
|
||||
|
||||
ValidateRequiredResponseFields(resp, eventType);
|
||||
|
||||
// Response ID consistency
|
||||
if (_responseId is not null && resp.TryGetProperty("id", out var idProp)
|
||||
&& idProp.GetString() != _responseId)
|
||||
{
|
||||
Fail("ID-01", $"Response ID changed: was '{_responseId}', now '{idProp.GetString()}'");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private void ValidateInProgress(string eventType)
|
||||
{
|
||||
if (_state != StreamState.InProgress)
|
||||
{
|
||||
Fail("ORDER-04", $"'{eventType}' received in state {_state} (expected InProgress)");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateStateTransition(string eventType, StreamState expected, StreamState next)
|
||||
{
|
||||
if (_state != expected)
|
||||
{
|
||||
Fail("ORDER-05", $"'{eventType}' received in state {_state} (expected {expected})");
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = next;
|
||||
}
|
||||
}
|
||||
|
||||
private void Fail(string ruleId, string message)
|
||||
{
|
||||
_violations.Add(new ValidationViolation(ruleId, message, _eventCount));
|
||||
}
|
||||
|
||||
private static bool HasNonNullString(JsonElement obj, string property)
|
||||
{
|
||||
return obj.TryGetProperty(property, out var prop)
|
||||
&& prop.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrEmpty(prop.GetString());
|
||||
}
|
||||
|
||||
private static string GetContentPartKey(JsonElement data)
|
||||
{
|
||||
int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1;
|
||||
int partIdx = data.TryGetProperty("content_index", out var pi) ? pi.GetInt32() : -1;
|
||||
return $"{outputIdx}:{partIdx}";
|
||||
}
|
||||
|
||||
private static string GetTextKey(JsonElement data)
|
||||
{
|
||||
int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1;
|
||||
int contentIdx = data.TryGetProperty("content_index", out var ci) ? ci.GetInt32() : -1;
|
||||
return $"{outputIdx}:{contentIdx}";
|
||||
}
|
||||
|
||||
private enum StreamState
|
||||
{
|
||||
Initial,
|
||||
Created,
|
||||
Queued,
|
||||
InProgress,
|
||||
Terminal,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A single validation violation.</summary>
|
||||
/// <param name="RuleId">The rule identifier (e.g. SEQ-01, FIELD-02).</param>
|
||||
/// <param name="Message">Human-readable description of the violation.</param>
|
||||
/// <param name="EventIndex">1-based index of the event that triggered this violation.</param>
|
||||
internal sealed record ValidationViolation(string RuleId, string Message, int EventIndex);
|
||||
|
||||
/// <summary>Overall validation result.</summary>
|
||||
/// <param name="EventCount">Total number of events processed.</param>
|
||||
/// <param name="IsValid">True if no violations were found.</param>
|
||||
/// <param name="Violations">List of all violations.</param>
|
||||
internal sealed record ValidationResult(int EventCount, bool IsValid, IReadOnlyList<ValidationViolation> Violations);
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ResponseHandler"/> implementation that bridges the Azure AI Responses Server SDK
|
||||
/// with agent-framework <see cref="AIAgent"/> instances, enabling agent-framework agents and workflows
|
||||
/// to be hosted as Azure Foundry Hosted Agents.
|
||||
/// </summary>
|
||||
public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
|
||||
/// that resolves agents from keyed DI services.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider for resolving agents.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public AgentFrameworkResponseHandler(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AgentFrameworkResponseHandler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
this._serviceProvider = serviceProvider;
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
|
||||
CreateResponse request,
|
||||
ResponseContext context,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Resolve agent
|
||||
var agent = this.ResolveAgent(request);
|
||||
|
||||
// 2. Create the SDK event stream builder
|
||||
var stream = new ResponseEventStream(context, request);
|
||||
|
||||
// 3. Emit lifecycle events
|
||||
yield return stream.EmitCreated();
|
||||
yield return stream.EmitInProgress();
|
||||
|
||||
// 4. Convert input: history + current input → ChatMessage[]
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Load conversation history if available
|
||||
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history));
|
||||
}
|
||||
|
||||
// Load and convert current input items
|
||||
var inputItems = await context.GetInputItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (inputItems.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(inputItems));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to raw request input
|
||||
messages.AddRange(InputConverter.ConvertInputToMessages(request));
|
||||
}
|
||||
|
||||
// 5. Build chat options
|
||||
var chatOptions = InputConverter.ConvertToChatOptions(request);
|
||||
chatOptions.Instructions = request.Instructions;
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// 6. Run the agent and convert output
|
||||
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
|
||||
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
|
||||
bool emittedTerminal = false;
|
||||
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
|
||||
agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken),
|
||||
stream,
|
||||
cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
bool shutdownDetected = false;
|
||||
ResponseStreamEvent? evt = null;
|
||||
try
|
||||
{
|
||||
if (!await enumerator.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
evt = enumerator.Current;
|
||||
}
|
||||
catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal)
|
||||
{
|
||||
shutdownDetected = true;
|
||||
}
|
||||
|
||||
if (shutdownDetected)
|
||||
{
|
||||
// Server is shutting down — emit incomplete so clients can resume
|
||||
this._logger.LogInformation("Shutdown detected, emitting incomplete response.");
|
||||
yield return stream.EmitIncomplete();
|
||||
yield break;
|
||||
}
|
||||
|
||||
// yield is in the outer try (finally-only) — allowed by C#
|
||||
yield return evt!;
|
||||
|
||||
if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent)
|
||||
{
|
||||
emittedTerminal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <see cref="AIAgent"/> from the request.
|
||||
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
|
||||
/// If neither is present, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
private AIAgent ResolveAgent(CreateResponse request)
|
||||
{
|
||||
var agentName = GetAgentName(request);
|
||||
|
||||
if (!string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
|
||||
if (agent is not null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
this._logger.LogWarning("Agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName);
|
||||
}
|
||||
|
||||
// Try non-keyed default
|
||||
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
|
||||
if (defaultAgent is not null)
|
||||
{
|
||||
return defaultAgent;
|
||||
}
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent.";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
private static string? GetAgentName(CreateResponse request)
|
||||
{
|
||||
// Try agent.name from AgentReference
|
||||
var agentName = request.AgentReference?.Name;
|
||||
|
||||
// Fall back to "model" field (OpenAI clients send the agent name as the model)
|
||||
if (string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
agentName = request.Model;
|
||||
}
|
||||
|
||||
// Fall back to metadata["entity_id"]
|
||||
if (string.IsNullOrEmpty(agentName) && request.Metadata?.AdditionalProperties is not null)
|
||||
{
|
||||
request.Metadata.AdditionalProperties.TryGetValue("entity_id", out agentName);
|
||||
}
|
||||
|
||||
return agentName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
|
||||
|
||||
/// <summary>
|
||||
/// Converts Responses Server SDK input types to agent-framework <see cref="ChatMessage"/> types.
|
||||
/// </summary>
|
||||
internal static class InputConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the SDK <see cref="CreateResponse"/> request input items into a list of <see cref="ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request from the SDK.</param>
|
||||
/// <returns>A list of chat messages representing the request input.</returns>
|
||||
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in request.GetInputExpanded())
|
||||
{
|
||||
var message = ConvertInputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts resolved SDK <see cref="OutputItem"/> history/input items into <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved output items from the SDK context.</param>
|
||||
/// <returns>A list of chat messages.</returns>
|
||||
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var message = ConvertOutputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="ChatOptions"/> from the SDK request properties.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <returns>A configured <see cref="ChatOptions"/> instance.</returns>
|
||||
public static ChatOptions ConvertToChatOptions(CreateResponse request)
|
||||
{
|
||||
return new ChatOptions
|
||||
{
|
||||
Temperature = (float?)request.Temperature,
|
||||
TopP = (float?)request.TopP,
|
||||
MaxOutputTokens = (int?)request.MaxOutputTokens,
|
||||
ModelId = request.Model,
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertInputItemToMessage(Item item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
ItemMessage msg => ConvertItemMessage(msg),
|
||||
FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput),
|
||||
ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall),
|
||||
ItemReferenceParam => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertItemMessage(ItemMessage msg)
|
||||
{
|
||||
var role = ConvertMessageRole(msg.Role);
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
foreach (var content in msg.GetContentExpanded())
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case MessageContentInputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contents.Count == 0)
|
||||
{
|
||||
contents.Add(new MeaiTextContent(string.Empty));
|
||||
}
|
||||
|
||||
return new ChatMessage(role, contents);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
|
||||
{
|
||||
var output = funcOutput.Output?.ToString() ?? string.Empty;
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, output)]);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK input.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK input.")]
|
||||
private static ChatMessage ConvertItemFunctionToolCall(ItemFunctionToolCall funcCall)
|
||||
{
|
||||
IDictionary<string, object?>? arguments = null;
|
||||
if (funcCall.Arguments is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
arguments = JsonSerializer.Deserialize<Dictionary<string, object?>>(funcCall.Arguments);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
arguments = new Dictionary<string, object?> { ["_raw"] = funcCall.Arguments };
|
||||
}
|
||||
}
|
||||
|
||||
return new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
|
||||
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
|
||||
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
|
||||
OutputItemReasoningItem => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertOutputItemMessageToChat(OutputItemMessage msg)
|
||||
{
|
||||
var role = ConvertMessageRole(msg.Role);
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
foreach (var content in msg.Content)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case MessageContentInputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case MessageContentOutputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case MessageContentRefusalContent refusal:
|
||||
contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]"));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contents.Count == 0)
|
||||
{
|
||||
contents.Add(new MeaiTextContent(string.Empty));
|
||||
}
|
||||
|
||||
return new ChatMessage(role, contents);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall)
|
||||
{
|
||||
IDictionary<string, object?>? arguments = null;
|
||||
if (funcCall.Arguments is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
arguments = JsonSerializer.Deserialize<Dictionary<string, object?>>(funcCall.Arguments);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
arguments = new Dictionary<string, object?> { ["_raw"] = funcCall.Arguments };
|
||||
}
|
||||
}
|
||||
|
||||
return new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
|
||||
{
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
|
||||
}
|
||||
|
||||
private static ChatRole ConvertMessageRole(MessageRole role)
|
||||
{
|
||||
return role switch
|
||||
{
|
||||
MessageRole.User => ChatRole.User,
|
||||
MessageRole.Assistant => ChatRole.Assistant,
|
||||
MessageRole.System => ChatRole.System,
|
||||
MessageRole.Developer => new ChatRole("developer"),
|
||||
_ => ChatRole.User
|
||||
};
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.AzureAIResponses</RootNamespace>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001;NU1903</NoWarn>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,346 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
|
||||
|
||||
/// <summary>
|
||||
/// Converts agent-framework <see cref="AgentResponseUpdate"/> streams into
|
||||
/// Responses Server SDK <see cref="ResponseStreamEvent"/> sequences using the
|
||||
/// <see cref="ResponseEventStream"/> builder pattern.
|
||||
/// </summary>
|
||||
internal static class OutputConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a stream of <see cref="AgentResponseUpdate"/> into a stream of
|
||||
/// <see cref="ResponseStreamEvent"/> using the SDK builder pattern.
|
||||
/// </summary>
|
||||
/// <param name="updates">The agent response updates to convert.</param>
|
||||
/// <param name="stream">The SDK event stream builder.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")]
|
||||
public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEventsAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
ResponseEventStream stream,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ResponseUsage? accumulatedUsage = null;
|
||||
OutputItemMessageBuilder? currentMessageBuilder = null;
|
||||
TextContentBuilder? currentTextBuilder = null;
|
||||
StringBuilder? accumulatedText = null;
|
||||
string? previousMessageId = null;
|
||||
bool hasTerminalEvent = false;
|
||||
var executorItemIds = new Dictionary<string, string>();
|
||||
|
||||
await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Handle workflow events from RawRepresentation
|
||||
if (update.RawRepresentation is WorkflowEvent workflowEvent)
|
||||
{
|
||||
// Close any open message builder before emitting workflow items
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
foreach (var evt in EmitWorkflowEvent(stream, workflowEvent, executorItemIds))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case MeaiTextContent textContent:
|
||||
{
|
||||
if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null)
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
}
|
||||
|
||||
previousMessageId = update.MessageId;
|
||||
|
||||
if (currentMessageBuilder is null)
|
||||
{
|
||||
currentMessageBuilder = stream.AddOutputItemMessage();
|
||||
yield return currentMessageBuilder.EmitAdded();
|
||||
|
||||
currentTextBuilder = currentMessageBuilder.AddTextContent();
|
||||
yield return currentTextBuilder.EmitAdded();
|
||||
|
||||
accumulatedText = new StringBuilder();
|
||||
}
|
||||
|
||||
if (textContent.Text is { Length: > 0 })
|
||||
{
|
||||
accumulatedText!.Append(textContent.Text);
|
||||
yield return currentTextBuilder!.EmitDelta(textContent.Text);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case FunctionCallContent funcCall:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N");
|
||||
var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId);
|
||||
yield return funcBuilder.EmitAdded();
|
||||
|
||||
var arguments = funcCall.Arguments is not null
|
||||
? JsonSerializer.Serialize(funcCall.Arguments)
|
||||
: "{}";
|
||||
|
||||
yield return funcBuilder.EmitArgumentsDelta(arguments);
|
||||
yield return funcBuilder.EmitArgumentsDone(arguments);
|
||||
yield return funcBuilder.EmitDone();
|
||||
break;
|
||||
}
|
||||
|
||||
case TextReasoningContent reasoningContent:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
var reasoningBuilder = stream.AddOutputItemReasoningItem();
|
||||
yield return reasoningBuilder.EmitAdded();
|
||||
|
||||
var summaryPart = reasoningBuilder.AddSummaryPart();
|
||||
yield return summaryPart.EmitAdded();
|
||||
|
||||
var text = reasoningContent.Text ?? string.Empty;
|
||||
yield return summaryPart.EmitTextDelta(text);
|
||||
yield return summaryPart.EmitTextDone(text);
|
||||
yield return summaryPart.EmitDone();
|
||||
reasoningBuilder.EmitSummaryPartDone(summaryPart);
|
||||
|
||||
yield return reasoningBuilder.EmitDone();
|
||||
break;
|
||||
}
|
||||
|
||||
case UsageContent usageContent when usageContent.Details is not null:
|
||||
{
|
||||
accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage);
|
||||
break;
|
||||
}
|
||||
|
||||
case ErrorContent errorContent:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
hasTerminalEvent = true;
|
||||
|
||||
yield return stream.EmitFailed(
|
||||
ResponseErrorCode.ServerError,
|
||||
errorContent.Message ?? "An error occurred during agent execution.",
|
||||
accumulatedUsage);
|
||||
yield break;
|
||||
}
|
||||
|
||||
case DataContent:
|
||||
case UriContent:
|
||||
// Image/audio/file content from agents is not currently supported
|
||||
// as streaming output items in the Responses Server SDK builder pattern.
|
||||
// These would need to be serialized as base64 or URL references.
|
||||
break;
|
||||
|
||||
case FunctionResultContent:
|
||||
// Function results are internal to the agent's tool-calling loop
|
||||
// and are not emitted as output items in the response stream.
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining open message
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
if (!hasTerminalEvent)
|
||||
{
|
||||
yield return stream.EmitCompleted(accumulatedUsage);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ResponseStreamEvent> CloseCurrentMessage(
|
||||
OutputItemMessageBuilder? messageBuilder,
|
||||
TextContentBuilder? textBuilder,
|
||||
StringBuilder? accumulatedText)
|
||||
{
|
||||
if (messageBuilder is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (textBuilder is not null)
|
||||
{
|
||||
var finalText = accumulatedText?.ToString() ?? string.Empty;
|
||||
yield return textBuilder.EmitDone(finalText);
|
||||
yield return messageBuilder.EmitContentDone(textBuilder);
|
||||
}
|
||||
|
||||
yield return messageBuilder.EmitDone();
|
||||
}
|
||||
|
||||
private static bool IsSameMessage(string? currentId, string? previousId) =>
|
||||
currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId;
|
||||
|
||||
private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing)
|
||||
{
|
||||
var inputTokens = (long)(details.InputTokenCount ?? 0);
|
||||
var outputTokens = (long)(details.OutputTokenCount ?? 0);
|
||||
var totalTokens = (long)(details.TotalTokenCount ?? 0);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
inputTokens += existing.InputTokens;
|
||||
outputTokens += existing.OutputTokens;
|
||||
totalTokens += existing.TotalTokens;
|
||||
}
|
||||
|
||||
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
totalTokens: totalTokens);
|
||||
}
|
||||
|
||||
private static IEnumerable<ResponseStreamEvent> EmitWorkflowEvent(
|
||||
ResponseEventStream stream,
|
||||
WorkflowEvent workflowEvent,
|
||||
Dictionary<string, string> executorItemIds)
|
||||
{
|
||||
switch (workflowEvent)
|
||||
{
|
||||
case ExecutorInvokedEvent invokedEvent:
|
||||
{
|
||||
var itemId = GenerateItemId("wfa");
|
||||
executorItemIds[invokedEvent.ExecutorId] = itemId;
|
||||
|
||||
var item = new WorkflowActionOutputItem(
|
||||
kind: "InvokeExecutor",
|
||||
actionId: invokedEvent.ExecutorId,
|
||||
status: WorkflowActionOutputItemStatus.InProgress,
|
||||
id: itemId);
|
||||
|
||||
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
|
||||
yield return builder.EmitAdded(item);
|
||||
yield return builder.EmitDone(item);
|
||||
break;
|
||||
}
|
||||
|
||||
case ExecutorCompletedEvent completedEvent:
|
||||
{
|
||||
var itemId = GenerateItemId("wfa");
|
||||
|
||||
var item = new WorkflowActionOutputItem(
|
||||
kind: "InvokeExecutor",
|
||||
actionId: completedEvent.ExecutorId,
|
||||
status: WorkflowActionOutputItemStatus.Completed,
|
||||
id: itemId);
|
||||
|
||||
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
|
||||
yield return builder.EmitAdded(item);
|
||||
yield return builder.EmitDone(item);
|
||||
executorItemIds.Remove(completedEvent.ExecutorId);
|
||||
break;
|
||||
}
|
||||
|
||||
case ExecutorFailedEvent failedEvent:
|
||||
{
|
||||
var itemId = GenerateItemId("wfa");
|
||||
|
||||
var item = new WorkflowActionOutputItem(
|
||||
kind: "InvokeExecutor",
|
||||
actionId: failedEvent.ExecutorId,
|
||||
status: WorkflowActionOutputItemStatus.Failed,
|
||||
id: itemId);
|
||||
|
||||
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
|
||||
yield return builder.EmitAdded(item);
|
||||
yield return builder.EmitDone(item);
|
||||
executorItemIds.Remove(failedEvent.ExecutorId);
|
||||
break;
|
||||
}
|
||||
|
||||
// Informational/lifecycle events — no SDK output needed.
|
||||
// Note: AgentResponseUpdateEvent and WorkflowErrorEvent are unwrapped by
|
||||
// WorkflowSession.InvokeStageAsync() into regular AgentResponseUpdate objects
|
||||
// with populated Contents (TextContent, ErrorContent, etc.), so they flow
|
||||
// through the normal content processing path above — not through this method.
|
||||
case SuperStepStartedEvent:
|
||||
case SuperStepCompletedEvent:
|
||||
case WorkflowStartedEvent:
|
||||
case WorkflowWarningEvent:
|
||||
case RequestInfoEvent:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a valid item ID matching the SDK's <c>{prefix}_{50chars}</c> format.
|
||||
/// </summary>
|
||||
private static string GenerateItemId(string prefix)
|
||||
{
|
||||
// SDK format: {prefix}_{50 char body}
|
||||
var bytes = RandomNumberGenerator.GetBytes(25);
|
||||
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
|
||||
return $"{prefix}_{body}";
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IServiceCollection"/> to register the agent-framework
|
||||
/// response handler with the Azure AI Responses Server SDK.
|
||||
/// </summary>
|
||||
public static class AgentFrameworkResponsesServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers <see cref="AgentFrameworkResponseHandler"/> as the <see cref="ResponseHandler"/>
|
||||
/// for the Azure AI Responses Server SDK. Agents are resolved from keyed DI services
|
||||
/// using the <c>agent.name</c> or <c>metadata["entity_id"]</c> from incoming requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Call this method <b>after</b> <c>AddResponsesServer()</c> and after registering your
|
||||
/// <see cref="AIAgent"/> instances (e.g., via <c>AddAIAgent()</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.Services.AddResponsesServer();
|
||||
/// builder.AddAIAgent("my-agent", ...);
|
||||
/// builder.Services.AddAgentFrameworkHandler();
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
/// app.MapResponsesServer();
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a specific <see cref="AIAgent"/> as the handler for all incoming requests,
|
||||
/// regardless of the <c>agent.name</c> in the request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Use this overload when hosting a single agent. The provided agent instance is
|
||||
/// registered both as a keyed service and as the default <see cref="AIAgent"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.Services.AddResponsesServer();
|
||||
/// builder.Services.AddAgentFrameworkHandler(myAgent);
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
/// app.MapResponsesServer();
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="agent">The agent instance to register.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services, AIAgent agent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.TryAddSingleton(agent);
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+815
@@ -0,0 +1,815 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests;
|
||||
|
||||
public class AgentFrameworkResponseHandlerTests
|
||||
{
|
||||
private static string ValidResponseId => "resp_" + new string('0', 46);
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithDefaultAgent_ProducesStreamEvents()
|
||||
{
|
||||
// Arrange
|
||||
var agent = CreateTestAgent("Hello from the agent!");
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithKeyedAgent_ResolvesCorrectAgent()
|
||||
{
|
||||
// Arrange
|
||||
var agent = CreateTestAgent("Keyed agent response");
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-agent"));
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert - should have produced events from the keyed agent
|
||||
Assert.True(events.Count >= 4);
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_NoAgentRegistered_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullServiceProvider_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => new AgentFrameworkResponseHandler(null!, NullLogger<AgentFrameworkResponseHandler>.Instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullLogger_ThrowsArgumentNullException()
|
||||
{
|
||||
var sp = new ServiceCollection().BuildServiceProvider();
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => new AgentFrameworkResponseHandler(sp, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ResolvesAgentByModelField()
|
||||
{
|
||||
// Arrange
|
||||
var agent = CreateTestAgent("model agent");
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton<AIAgent>("my-agent", agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(events.Count >= 4);
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ResolvesAgentByEntityIdMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var agent = CreateTestAgent("entity agent");
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton<AIAgent>("entity-agent", agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
var metadata = new Metadata();
|
||||
metadata.AdditionalProperties["entity_id"] = "entity-agent";
|
||||
request.Metadata = metadata;
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(events.Count >= 4);
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_NamedAgentNotFound_FallsBackToDefault()
|
||||
{
|
||||
// Arrange
|
||||
var agent = CreateTestAgent("default agent");
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("nonexistent-agent"));
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(events.Count >= 4);
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_NoAgentFound_ErrorMessageIncludesAgentName()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("missing-agent"));
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Contains("missing-agent", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_NoAgentNoName_ErrorMessageIsGeneric()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Contains("No agent name specified", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AgentResolvedBeforeEmitCreated_ExceptionHasNoEvents()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
bool threw = false;
|
||||
try
|
||||
{
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
threw = true;
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(threw);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithHistory_PrependsHistoryToMessages()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new CapturingAgent();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var historyItem = new OutputItemMessage(
|
||||
id: "hist_1",
|
||||
role: MessageRole.Assistant,
|
||||
content: [new MessageContentOutputTextContent(
|
||||
"Previous response",
|
||||
Array.Empty<Annotation>(),
|
||||
Array.Empty<LogProb>())],
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new OutputItem[] { historyItem });
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.CapturedMessages);
|
||||
var messages = agent.CapturedMessages.ToList();
|
||||
Assert.True(messages.Count >= 2);
|
||||
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithInputItems_UsesResolvedInputItems()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new CapturingAgent();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Raw input" } } }
|
||||
});
|
||||
|
||||
var inputItem = new OutputItemMessage(
|
||||
id: "input_1",
|
||||
role: MessageRole.Assistant,
|
||||
content: [new MessageContentOutputTextContent(
|
||||
"Resolved input",
|
||||
Array.Empty<Annotation>(),
|
||||
Array.Empty<LogProb>())],
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new OutputItem[] { inputItem });
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.CapturedMessages);
|
||||
var messages = agent.CapturedMessages.ToList();
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_NoInputItems_FallsBackToRawRequestInput()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new CapturingAgent();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Raw input" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.CapturedMessages);
|
||||
var messages = agent.CapturedMessages.ToList();
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.User, messages[0].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_PassesInstructionsToAgent()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new CapturingAgent();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
instructions: "You are a helpful assistant.");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.CapturedOptions);
|
||||
var chatClientOptions = Assert.IsType<ChatClientAgentRunOptions>(agent.CapturedOptions);
|
||||
Assert.Equal("You are a helpful assistant.", chatClientOptions.ChatOptions?.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_AgentThrows_ExceptionPropagates()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new ThrowingAgent(new InvalidOperationException("Agent crashed"));
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_MultipleKeyedAgents_ResolvesCorrectOne()
|
||||
{
|
||||
// Arrange
|
||||
var agent1 = CreateTestAgent("Agent 1 response");
|
||||
var agent2 = CreateTestAgent("Agent 2 response");
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton<AIAgent>("agent-1", agent1);
|
||||
services.AddKeyedSingleton<AIAgent>("agent-2", agent2);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("agent-2"));
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
// Act
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(events.Count >= 4);
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_CancellationDuringExecution_PropagatesOperationCanceledException()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new CancellationCheckingAgent();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } } }
|
||||
});
|
||||
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mockContext.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await foreach (var _ in handler.CreateAsync(request, mockContext.Object, cts.Token))
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static TestAgent CreateTestAgent(string responseText)
|
||||
{
|
||||
return new TestAgent(responseText);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(params AgentResponseUpdate[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestAgent(string responseText) : AIAgent
|
||||
{
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ToAsyncEnumerableAsync(new AgentResponseUpdate
|
||||
{
|
||||
MessageId = "resp_msg_1",
|
||||
Contents = [new MeaiTextContent(responseText)]
|
||||
});
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class ThrowingAgent(Exception exception) : AIAgent
|
||||
{
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw exception;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class CapturingAgent : AIAgent
|
||||
{
|
||||
public IEnumerable<ChatMessage>? CapturedMessages { get; private set; }
|
||||
public AgentRunOptions? CapturedOptions { get; private set; }
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CapturedMessages = messages.ToList();
|
||||
CapturedOptions = options;
|
||||
return ToAsyncEnumerableAsync(new AgentResponseUpdate
|
||||
{
|
||||
MessageId = "resp_msg_1",
|
||||
Contents = [new MeaiTextContent("captured")]
|
||||
});
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class CancellationCheckingAgent : AIAgent
|
||||
{
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return new AgentResponseUpdate { Contents = [new MeaiTextContent("test")] };
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
+671
@@ -0,0 +1,671 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests;
|
||||
|
||||
public class InputConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_EmptyRequest_ReturnsEmptyList()
|
||||
{
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(Array.Empty<object>());
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Empty(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_UserTextMessage_ReturnsUserMessage()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_001",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello, agent!" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.User, messages[0].Role);
|
||||
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello, agent!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FunctionCallOutput_ReturnsToolMessage()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "function_call_output",
|
||||
id = "fc_out_001",
|
||||
call_id = "call_123",
|
||||
output = "42"
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.Tool, messages[0].Role);
|
||||
var funcResult = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
|
||||
Assert.NotNull(funcResult);
|
||||
Assert.Equal("call_123", funcResult.CallId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FunctionToolCall_ReturnsAssistantMessage()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "function_call",
|
||||
id = "fc_001",
|
||||
call_id = "call_456",
|
||||
name = "get_weather",
|
||||
arguments = "{\"location\": \"Seattle\"}"
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
||||
var funcCall = messages[0].Contents.OfType<FunctionCallContent>().FirstOrDefault();
|
||||
Assert.NotNull(funcCall);
|
||||
Assert.Equal("call_456", funcCall.CallId);
|
||||
Assert.Equal("get_weather", funcCall.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_MultipleItems_ReturnsAllMessages()
|
||||
{
|
||||
var input = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_001",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_text", text = "What's the weather?" } }
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "function_call",
|
||||
id = "fc_001",
|
||||
call_id = "call_789",
|
||||
name = "get_weather",
|
||||
arguments = "{}"
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "function_call_output",
|
||||
id = "fc_out_001",
|
||||
call_id = "call_789",
|
||||
output = "Sunny, 72°F"
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Equal(3, messages.Count);
|
||||
Assert.Equal(ChatRole.User, messages[0].Role);
|
||||
Assert.Equal(ChatRole.Assistant, messages[1].Role);
|
||||
Assert.Equal(ChatRole.Tool, messages[2].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_SetsTemperatureAndTopP()
|
||||
{
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 1000,
|
||||
model: "gpt-4o");
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
Assert.Equal(0.7f, options.Temperature);
|
||||
Assert.Equal(0.9f, options.TopP);
|
||||
Assert.Equal(1000, options.MaxOutputTokens);
|
||||
Assert.Equal("gpt-4o", options.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_NullValues_SetsNulls()
|
||||
{
|
||||
var request = new CreateResponse();
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
Assert.Null(options.Temperature);
|
||||
Assert.Null(options.TopP);
|
||||
Assert.Null(options.MaxOutputTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_OutputMessage_ReturnsAssistantMessage()
|
||||
{
|
||||
var textContent = new MessageContentOutputTextContent(
|
||||
"Hello from assistant",
|
||||
Array.Empty<Annotation>(),
|
||||
Array.Empty<LogProb>());
|
||||
var outputMsg = new OutputItemMessage(
|
||||
id: "out_001",
|
||||
role: MessageRole.Assistant,
|
||||
content: [textContent],
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
||||
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello from assistant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCall_ReturnsAssistantMessage()
|
||||
{
|
||||
var funcCall = new OutputItemFunctionToolCall(
|
||||
callId: "call_abc",
|
||||
name: "search",
|
||||
arguments: "{\"query\": \"test\"}");
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
||||
var content = messages[0].Contents.OfType<FunctionCallContent>().FirstOrDefault();
|
||||
Assert.NotNull(content);
|
||||
Assert.Equal("call_abc", content.CallId);
|
||||
Assert.Equal("search", content.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage()
|
||||
{
|
||||
var funcOutput = new FunctionToolCallOutputResource(
|
||||
callId: "call_def",
|
||||
output: BinaryData.FromString("result data"));
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([funcOutput]);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.Tool, messages[0].Role);
|
||||
var result = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("call_def", result.CallId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull()
|
||||
{
|
||||
var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem(
|
||||
id: "reason_001");
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]);
|
||||
|
||||
Assert.Empty(messages);
|
||||
}
|
||||
|
||||
// ── Image Content Tests (B-03 through B-06) ──
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_ImageContentWithHttpUrl_ReturnsUriContent()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_image", image_url = "https://example.com/img.png" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Contains(messages[0].Contents, c => c is UriContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_ImageContentWithDataUri_ReturnsDataContent()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_image", image_url = "data:image/png;base64,iVBORw0KGgo=" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Contains(messages[0].Contents, c => c is DataContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_ImageContentWithFileId_ReturnsHostedFileContent()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_image", file_id = "file_abc123" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Contains(messages[0].Contents, c => c is HostedFileContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_ImageContentNoUrlOrFileId_ProducesNoContent()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_image" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Single(messages[0].Contents);
|
||||
}
|
||||
|
||||
// ── File Content Tests (B-07 through B-11) ──
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithUrl_ReturnsUriContent()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_file", file_url = "https://example.com/doc.pdf" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Contains(messages[0].Contents, c => c is UriContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithInlineData_ReturnsDataContent()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_file", file_data = "data:application/pdf;base64,iVBORw0KGgo=" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Contains(messages[0].Contents, c => c is DataContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithFileId_ReturnsHostedFileContent()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_file", file_id = "file_xyz789" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Contains(messages[0].Contents, c => c is HostedFileContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithFilenameOnly_ReturnsFallbackText()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_file", filename = "report.pdf" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("report.pdf"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithNothing_ProducesNoContent()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_file" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Single(messages[0].Contents);
|
||||
}
|
||||
|
||||
// ── Mixed Content / Edge Cases (B-15 through B-18) ──
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_MixedContentInSingleMessage_ReturnsAllContentTypes()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new object[]
|
||||
{
|
||||
new { type = "input_text", text = "Look at this:" },
|
||||
new { type = "input_image", image_url = "https://example.com/img.png" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(2, messages[0].Contents.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_EmptyMessageContent_ReturnsFallbackTextContent()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = Array.Empty<object>()
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
var textContent = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(string.Empty, textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_OutputMessageRefusal_ReturnsRefusalText()
|
||||
{
|
||||
var refusal = new MessageContentRefusalContent("I cannot help with that");
|
||||
var outputMsg = new OutputItemMessage(
|
||||
id: "out_1",
|
||||
role: MessageRole.Assistant,
|
||||
content: [refusal],
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:"));
|
||||
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("I cannot help with that"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_ItemReferenceParam_IsSkipped()
|
||||
{
|
||||
var input = new object[]
|
||||
{
|
||||
new { type = "item_reference", id = "ref_001" },
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hello" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
}
|
||||
|
||||
// ── Role Mapping Tests (C-01 through C-05) ──
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_UserRole_ReturnsChatRoleUser()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_1",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_text", text = "Hi" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.User, messages[0].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_AssistantRole_ReturnsChatRoleAssistant()
|
||||
{
|
||||
// OutputItemMessage always maps to assistant role
|
||||
var textContent = new MessageContentOutputTextContent(
|
||||
"Hi", Array.Empty<Annotation>(), Array.Empty<LogProb>());
|
||||
var outputMsg = new OutputItemMessage(
|
||||
id: "msg_1",
|
||||
role: MessageRole.Assistant,
|
||||
content: [textContent],
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
||||
}
|
||||
|
||||
// ── History Conversion Edge Cases (D-02 through D-12) ──
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_OutputMessageWithRefusal_ReturnsRefusalText()
|
||||
{
|
||||
var refusal = new MessageContentRefusalContent("Not allowed");
|
||||
var outputMsg = new OutputItemMessage(
|
||||
id: "out_1",
|
||||
role: MessageRole.Assistant,
|
||||
content: [refusal],
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(ChatRole.Assistant, messages[0].Role);
|
||||
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:"));
|
||||
Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("Not allowed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_OutputMessageWithEmptyContent_ReturnsFallbackText()
|
||||
{
|
||||
var outputMsg = new OutputItemMessage(
|
||||
id: "out_1",
|
||||
role: MessageRole.Assistant,
|
||||
content: [],
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
||||
|
||||
Assert.Single(messages);
|
||||
var textContent = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(string.Empty, textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallWithMalformedArgs_UsesRawFallback()
|
||||
{
|
||||
var funcCall = new OutputItemFunctionToolCall(
|
||||
callId: "call_1",
|
||||
name: "test",
|
||||
arguments: "not-json{{{");
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]);
|
||||
|
||||
Assert.Single(messages);
|
||||
var content = messages[0].Contents.OfType<FunctionCallContent>().FirstOrDefault();
|
||||
Assert.NotNull(content);
|
||||
Assert.NotNull(content.Arguments);
|
||||
Assert.True(content.Arguments.ContainsKey("_raw"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_UnknownOutputItemType_IsSkipped()
|
||||
{
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([]);
|
||||
|
||||
Assert.Empty(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_ModelId_SetFromRequest()
|
||||
{
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model");
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
Assert.Equal("my-model", options.ModelId);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<NoWarn>$(NoWarn);NU1903;NU1605</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureAIResponses\Microsoft.Agents.AI.Hosting.AzureAIResponses.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1080
File diff suppressed because it is too large
Load Diff
+72
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddAgentFrameworkHandler_RegistersResponseHandler()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddAgentFrameworkHandler();
|
||||
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(ResponseHandler));
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(typeof(AgentFrameworkResponseHandler), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAgentFrameworkHandler_CalledTwice_RegistersOnce()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddAgentFrameworkHandler();
|
||||
services.AddAgentFrameworkHandler();
|
||||
|
||||
var count = services.Count(d => d.ServiceType == typeof(ResponseHandler));
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAgentFrameworkHandler_NullServices_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AgentFrameworkResponsesServiceCollectionExtensions.AddAgentFrameworkHandler(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAgentFrameworkHandler_WithAgent_RegistersAgentAndHandler()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
services.AddAgentFrameworkHandler(mockAgent.Object);
|
||||
|
||||
var handlerDescriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(ResponseHandler));
|
||||
Assert.NotNull(handlerDescriptor);
|
||||
|
||||
var agentDescriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(agentDescriptor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAgentFrameworkHandler_WithNullAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => services.AddAgentFrameworkHandler((AIAgent)null!));
|
||||
}
|
||||
}
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that verify workflow execution through the
|
||||
/// <see cref="AgentFrameworkResponseHandler"/> → <see cref="OutputConverter"/> pipeline.
|
||||
/// These use real workflow builders and the InProcessExecution environment
|
||||
/// to produce authentic streaming event patterns.
|
||||
/// </summary>
|
||||
public class WorkflowIntegrationTests
|
||||
{
|
||||
private static string ValidResponseId => "resp_" + new string('0', 46);
|
||||
|
||||
// ===== Sequential Workflow Tests =====
|
||||
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_SingleAgent_ProducesTextOutput()
|
||||
{
|
||||
// Arrange: single-agent sequential workflow
|
||||
var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "workflow-agent",
|
||||
name: "Test Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + at least one text output + terminal
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
|
||||
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBoth()
|
||||
{
|
||||
// Arrange: two agents in sequence
|
||||
var agent1 = new StreamingTextAgent("agent1", "First agent says hello");
|
||||
var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "seq-workflow",
|
||||
name: "Sequential Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have workflow action events for executor lifecycle
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
|
||||
// Should have output item events (either text messages or workflow actions)
|
||||
Assert.True(events.OfType<ResponseOutputItemAddedEvent>().Any(),
|
||||
"Expected at least one output item from the workflow");
|
||||
}
|
||||
|
||||
// ===== Workflow Error Propagation =====
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_AgentThrowsException_ProducesErrorOutput()
|
||||
{
|
||||
// Arrange: workflow with an agent that throws
|
||||
var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed"));
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "error-workflow",
|
||||
name: "Error Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + error/failure indicator
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
|
||||
var lastEvent = events[^1];
|
||||
// Workflow errors surface as either Failed or Completed (depending on error handling)
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
// ===== Workflow Action Lifecycle Events =====
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItems()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new StreamingTextAgent("test-agent", "Result");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "actions-workflow",
|
||||
name: "Actions Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: workflow should produce OutputItemAdded events for executor lifecycle
|
||||
var addedEvents = events.OfType<ResponseOutputItemAddedEvent>().ToList();
|
||||
Assert.True(addedEvents.Count >= 1,
|
||||
$"Expected at least 1 output item added event, got {addedEvents.Count}");
|
||||
}
|
||||
|
||||
// ===== Keyed Workflow Registration =====
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectly()
|
||||
{
|
||||
// Arrange: workflow agent registered with a keyed service name
|
||||
var agent = new StreamingTextAgent("inner", "Keyed workflow response");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "keyed-workflow",
|
||||
name: "Keyed Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton<AIAgent>("my-workflow", workflowAgent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-workflow"));
|
||||
request.Input = CreateUserInput("Test keyed workflow");
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, mockContext.Object);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}");
|
||||
}
|
||||
|
||||
// ===== OutputConverter Direct Workflow Pattern Tests =====
|
||||
// These test the OutputConverter directly with update patterns that mirror real workflows.
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_SequentialWorkflowPattern_ProducesCorrectEvents()
|
||||
{
|
||||
// Simulate what WorkflowSession produces for a 2-agent sequential workflow
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
// Superstep 1: Agent 1
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Superstep 2: Agent 2
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow action items + 2 text messages = 6 output items
|
||||
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_GroupChatPattern_ProducesCorrectEvents()
|
||||
{
|
||||
// Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 6 workflow actions + 3 text messages = 9 output items
|
||||
Assert.Equal(9, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(3, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_CodeExecutorPattern_ProducesCorrectEvents()
|
||||
{
|
||||
// Simulate a code-based FunctionExecutor: invoked → completed, no text content
|
||||
// (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle)
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Second executor uses the output
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow actions + 1 text message = 5 output items
|
||||
Assert.Equal(5, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_SubworkflowPattern_ProducesCorrectEvents()
|
||||
{
|
||||
// Simulate a parent workflow that invokes a sub-workflow executor
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
// Sub-workflow executor invoked
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") },
|
||||
// Inner agent within sub-workflow produces text (unwrapped by WorkflowSession)
|
||||
new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] },
|
||||
// Sub-workflow executor completed
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 2 workflow actions + 1 text message = 3 output items
|
||||
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_WorkflowWithMultipleContentTypes_HandlesAllCorrectly()
|
||||
{
|
||||
// Simulate a workflow producing reasoning, text, function calls, and usage
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") },
|
||||
// Reasoning
|
||||
new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] },
|
||||
// Function call (tool use)
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new FunctionCallContent("call_search", "web_search",
|
||||
new Dictionary<string, object?> { ["query"] = "latest news" })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) },
|
||||
// Next executor uses tool result
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] },
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Workflow actions: 4 (2 invoked + 2 completed)
|
||||
// Content: 1 reasoning + 1 function call + 1 text message = 3
|
||||
// Total: 7 output items
|
||||
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context)
|
||||
CreateHandlerWithAgent(AIAgent agent, string userMessage)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AIAgent>(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
return (handler, request, mockContext.Object);
|
||||
}
|
||||
|
||||
private static BinaryData CreateUserInput(string text)
|
||||
{
|
||||
return BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_in_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Mock<ResponseContext> CreateMockContext()
|
||||
{
|
||||
var mock = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mock.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mock.Setup(x => x.GetInputItemsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
private static async Task<List<ResponseStreamEvent>> CollectEventsAsync(
|
||||
AgentFrameworkResponseHandler handler,
|
||||
CreateResponse request,
|
||||
ResponseContext context)
|
||||
{
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
|
||||
{
|
||||
foreach (var item in source)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ===== Test Agent Types =====
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that streams a single text update.
|
||||
/// </summary>
|
||||
private sealed class StreamingTextAgent(string id, string responseText) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = $"msg_{id}",
|
||||
Contents = [new MeaiTextContent(responseText)]
|
||||
};
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that always throws an exception during streaming.
|
||||
/// </summary>
|
||||
private sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw exception;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user