/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
using Microsoft.Extensions.AI;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
namespace GitHub.Copilot;
public sealed partial class CopilotSession
{
///
/// Sends a prompt with a JSON Schema inferred from and
/// deserializes the final response into that type.
///
/// The expected response type.
/// The user message text.
/// Options used both for schema inference and deserialization.
/// Defaults to , as for custom tools.
/// For Native AOT, supply options with a source-generated type resolver.
/// Timeout duration (default: 60 seconds). Does not abort agent work.
/// Cancellation token for sending and waiting.
/// The non-null deserialized response.
[Experimental(Diagnostics.Experimental)]
public Task SendAndWaitAsync(
string prompt,
JsonSerializerOptions? serializerOptions = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(prompt);
return SendAndWaitAsync(new MessageOptions { Prompt = prompt }, serializerOptions, timeout, cancellationToken);
}
///
/// Sends a message with a JSON Schema inferred from and
/// deserializes the final response into that type.
///
/// The expected response type.
/// The message to send. Must not specify a response schema or immediate delivery.
/// Options used both for schema inference and deserialization.
/// Defaults to , as for custom tools.
/// For Native AOT, supply options with a source-generated type resolver.
/// Timeout duration (default: 60 seconds). Does not abort agent work.
/// Cancellation token for sending and waiting.
/// The non-null deserialized response.
/// The message specifies a response schema or immediate delivery.
/// No final response was received, or the session reported an error.
/// The response is not valid JSON for the requested type, or is null.
/// The response did not arrive within the timeout.
///
/// Uses the same Microsoft.Extensions.AI schema inference as custom tools. Property naming,
/// converters, required members and nullable annotations follow the supplied serialization
/// contracts. The inferred schema requests strict output with all properties required and
/// additional properties disallowed. Provider schema restrictions still apply.
/// Deserialization is not full JSON Schema validation; apply application-specific validation
/// to the returned value where needed. The supplied message options are not modified.
///
[Experimental(Diagnostics.Experimental)]
public async Task SendAndWaitAsync(
MessageOptions options,
JsonSerializerOptions? serializerOptions = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
ThrowIfDisposed();
if (options.ResponseSchema is not null)
{
throw new ArgumentException("The typed overload infers its response schema. Use the untyped overload for an explicit response schema.", nameof(options));
}
if (options.Mode == "immediate")
{
throw new ArgumentException("Structured output cannot be requested on an immediate steering message.", nameof(options));
}
serializerOptions = ResolveStructuredOutputOptions(serializerOptions);
var typeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(TResult));
var schema = AIJsonUtilities.CreateJsonSchema(
typeof(TResult),
serializerOptions: serializerOptions,
inferenceOptions: new AIJsonSchemaCreateOptions
{
TransformOptions = new AIJsonSchemaTransformOptions
{
RequireAllProperties = true,
DisallowAdditionalProperties = true,
MoveDefaultKeywordToDescription = true,
},
});
var message = options.Clone();
message.ResponseSchema = schema;
var response = await SendAndWaitForStructuredMessageAsync(message, timeout, cancellationToken);
return JsonSerializer.Deserialize(response.Data.Content, typeInfo)
?? throw new JsonException("The structured response was JSON null, not a result.");
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "The reflection resolver is only created when JsonSerializer.IsReflectionEnabledByDefault is enabled.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The reflection resolver is only created when JsonSerializer.IsReflectionEnabledByDefault is enabled.")]
private static JsonSerializerOptions ResolveStructuredOutputOptions(JsonSerializerOptions? options)
{
options ??= AIJsonUtilities.DefaultOptions;
if (options.IsReadOnly)
{
return options;
}
var resolved = new JsonSerializerOptions(options);
if (resolved.TypeInfoResolver is null && JsonSerializer.IsReflectionEnabledByDefault)
{
resolved.TypeInfoResolver = new DefaultJsonTypeInfoResolver();
}
resolved.MakeReadOnly();
return resolved;
}
private async Task SendAndWaitForStructuredMessageAsync(
MessageOptions options, TimeSpan? timeout, CancellationToken cancellationToken)
{
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(effectiveTimeout);
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = cts.Token.Register(() => completion.TrySetCanceled(cts.Token));
var gate = new object();
var pendingEvents = new List();
string? messageId = null;
var started = false;
AssistantMessageEvent? finalMessage = null;
void ProcessEvent(SessionEvent evt)
{
switch (evt)
{
case UserMessageEvent user when string.IsNullOrEmpty(user.AgentId) && user.Data.MessageId == messageId:
started = true;
break;
case AssistantMessageEvent assistant when string.IsNullOrEmpty(assistant.AgentId) && assistant.Data.OriginatingMessageId == messageId:
started = true;
finalMessage = assistant.Data.ToolRequests is { Length: > 0 } ? null : assistant;
break;
case SessionIdleEvent idle when started && string.IsNullOrEmpty(idle.AgentId) && idle.Data.Mode != SessionMode.Autopilot:
if (idle.Data.Aborted == true)
{
completion.TrySetException(new InvalidOperationException("The session was aborted before a final structured response was received."));
}
else if (finalMessage is null || string.IsNullOrWhiteSpace(finalMessage.Data.Content))
{
completion.TrySetException(new InvalidOperationException("The turn completed without a final structured response."));
}
else
{
completion.TrySetResult(finalMessage);
}
break;
case SessionErrorEvent error when started && string.IsNullOrEmpty(error.AgentId):
completion.TrySetException(new InvalidOperationException($"Session error: {error.Data.Message}"));
break;
}
}
using var subscription = On(evt =>
{
if (evt is not (UserMessageEvent or AssistantMessageEvent or SessionIdleEvent or SessionErrorEvent))
{
return;
}
lock (gate)
{
if (messageId is null)
{
// Events can arrive before the send RPC response supplies the logical message ID.
pendingEvents.Add(evt);
}
else
{
ProcessEvent(evt);
}
}
});
try
{
var sentMessageId = await SendAsync(options, cts.Token);
lock (gate)
{
messageId = sentMessageId;
foreach (var evt in pendingEvents)
{
ProcessEvent(evt);
}
pendingEvents.Clear();
}
await Task.WhenAny(completion.Task, JsonRpc.Completion, _eventChannel.Reader.Completion);
if (!completion.Task.IsCompleted)
{
throw new IOException("The session closed before a final structured response was received.");
}
return await completion.Task;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}");
}
}
}