From 1b110decda23bc5b86a8d4fac393dc4378cf4e8c Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Tue, 6 Oct 2026 11:33:38 -0600 Subject: [PATCH 1/3] feat!: mint file links at click time file.publish returns a sandbox:/ name instead of a signed URL. Listing a conversation's files and the stream's file frame carry facts only. IConversations.LinkFileAsync mints a five-minute link for one kept file when the person asks for it. BlobOwner.IsSafe is the one owner id rule, shared by the S3 store and the link lookup. Breaking: FileLink, Conversations.LinkFilesAsync, and the file frame's url field are gone. StoredConversation.Files is now a list of BlobRef. --- config/example.yaml | 4 +- src/AgentCore.Application/Blobs/BlobLink.cs | 2 +- src/AgentCore.Application/Blobs/BlobOwner.cs | 16 ++ src/AgentCore.Application/Blobs/FileLink.cs | 7 - .../Conversation/Conversations.cs | 29 +-- .../Conversation/StoredConversation.cs | 7 +- .../Ports/IConversations.cs | 16 +- .../PublicAPI.Unshipped.txt | 25 +-- .../Tools/Builtin/FilePublishTool.cs | 14 +- .../Builtin/FilePublishToolDefinition.cs | 7 +- .../Endpoints/ResponsesContracts.cs | 3 - .../Endpoints/ResponsesTurnStream.cs | 5 +- .../Endpoints/TurnStreamFiles.cs | 18 +- .../Blobs/S3/S3BlobStore.cs | 6 +- .../Blobs/BlobLinkTests.cs | 6 +- .../Conversation/ConversationsTests.cs | 168 ++++++++++++++---- .../Tools/FilePublishBackgroundChildTests.cs | 2 +- .../Tools/FilePublishGraphParticipantTests.cs | 2 +- .../Tools/FilePublishToolDefinitionTests.cs | 26 ++- .../Endpoints/FileWireTests.cs | 19 +- .../Endpoints/TurnStreamFilesTests.cs | 39 ++-- 21 files changed, 268 insertions(+), 153 deletions(-) create mode 100644 src/AgentCore.Application/Blobs/BlobOwner.cs delete mode 100644 src/AgentCore.Application/Blobs/FileLink.cs diff --git a/config/example.yaml b/config/example.yaml index cab7f2fa..6ed935dd 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -194,9 +194,7 @@ tools: - { id: search, kind: builtin, uses: web.search } # kind: builtin, a plain function. The model calls it when a file in the conversation's workspace is a - # deliverable: the file is copied into providers.blobs (see 8. providers), owned by the conversation, - # and the person gets a link. Files not published are lost when the conversation ends. Needs - # providers.blobs and options.UseWorkspace(...) on the host; missing either fails the boot. + # deliverable: the file is copied into providers.blobs. - { id: publish, kind: builtin, uses: file.publish } # kind: binding, typed form. Not used in this example. The host registered diff --git a/src/AgentCore.Application/Blobs/BlobLink.cs b/src/AgentCore.Application/Blobs/BlobLink.cs index a2d5237d..be7ff0e0 100644 --- a/src/AgentCore.Application/Blobs/BlobLink.cs +++ b/src/AgentCore.Application/Blobs/BlobLink.cs @@ -4,7 +4,7 @@ namespace AgentCore.Application.Blobs public static class BlobLink { /// How long a link works. - public static TimeSpan Lifetime { get; } = TimeSpan.FromMinutes(15); + public static TimeSpan Lifetime { get; } = TimeSpan.FromMinutes(5); /// /// The Content-Disposition a link serves the blob with: inline for what a browser diff --git a/src/AgentCore.Application/Blobs/BlobOwner.cs b/src/AgentCore.Application/Blobs/BlobOwner.cs new file mode 100644 index 00000000..4e35bacc --- /dev/null +++ b/src/AgentCore.Application/Blobs/BlobOwner.cs @@ -0,0 +1,16 @@ +namespace AgentCore.Application.Blobs +{ + /// + /// The one shape a blob owner id may take. An owner id is the first segment of a key, never a path. + /// + public static class BlobOwner + { + /// Says whether an owner id is safe to use as the first segment of a key. + /// The conversation id or other owner the caller wrote. + /// when the id is not blank and holds no slash. + public static bool IsSafe(string? ownerId) + { + return !string.IsNullOrWhiteSpace(ownerId) && !ownerId.Contains('/', StringComparison.Ordinal); + } + } +} diff --git a/src/AgentCore.Application/Blobs/FileLink.cs b/src/AgentCore.Application/Blobs/FileLink.cs deleted file mode 100644 index ef831b6c..00000000 --- a/src/AgentCore.Application/Blobs/FileLink.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace AgentCore.Application.Blobs -{ - /// One file a conversation's sandbox wrote and the store kept. - /// The file's facts. - /// Where a browser fetches it from, or when the store has no web door. - public sealed record FileLink(BlobRef Blob, Uri? Url); -} diff --git a/src/AgentCore.Application/Conversation/Conversations.cs b/src/AgentCore.Application/Conversation/Conversations.cs index a04ec4ae..f66db0f9 100644 --- a/src/AgentCore.Application/Conversation/Conversations.cs +++ b/src/AgentCore.Application/Conversation/Conversations.cs @@ -45,7 +45,7 @@ public Conversations(IConversationStore conversations, IBlobStore? blobs) IReadOnlyList messages = await Store.ReadWindowAsync(conversationId, window, cancellationToken).ConfigureAwait(false); - IReadOnlyList files = await LinkFilesAsync(conversationId, messages.Select(message => message.Content), cancellationToken).ConfigureAwait(false); + IReadOnlyList files = KeptFiles(conversationId, messages.Select(message => message.Content)); int turns = messages.Select(message => message.TurnIndex).Distinct().Count(); @@ -55,15 +55,11 @@ public Conversations(IConversationStore conversations, IBlobStore? blobs) }; } - /// Links every published file the messages carry and the store kept, each name once, in first-seen order. + /// Lists every published file the messages carry and the store kept, each name once, in first-seen order. /// The conversation that owns the files. /// The messages to read the references off: the stored transcript, or one turn's updates. - /// Cancels the signing. - /// One link per kept file. - public async Task> LinkFilesAsync( - string conversationId, - IEnumerable messages, - CancellationToken cancellationToken = default) + /// The facts of each kept file. The last mention of a name wins its place's facts. + internal IReadOnlyList KeptFiles(string conversationId, IEnumerable messages) { ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); ArgumentNullException.ThrowIfNull(messages); @@ -95,15 +91,22 @@ public async Task> LinkFilesAsync( } } - List links = []; + return kept; + } - foreach (BlobRef blob in kept) + /// + public async ValueTask LinkFileAsync(string conversationId, string name, CancellationToken cancellationToken = default) + { + if (_blobs is null || !BlobOwner.IsSafe(conversationId) || !BlobName.IsSafe(name)) { - Uri? url = await _blobs.LinkAsync(blob, BlobLink.Lifetime, cancellationToken).ConfigureAwait(false); - links.Add(new FileLink(blob, url)); + return null; } - return links; + BlobRef? blob = await _blobs.StatAsync(conversationId, name, cancellationToken).ConfigureAwait(false); + + return blob is null + ? null + : await _blobs.LinkAsync(blob, BlobLink.Lifetime, cancellationToken).ConfigureAwait(false); } /// diff --git a/src/AgentCore.Application/Conversation/StoredConversation.cs b/src/AgentCore.Application/Conversation/StoredConversation.cs index 4a908714..062326ca 100644 --- a/src/AgentCore.Application/Conversation/StoredConversation.cs +++ b/src/AgentCore.Application/Conversation/StoredConversation.cs @@ -1,4 +1,5 @@ using AgentCore.Application.Blobs; +using AgentCore.Application.Ports; using AgentCore.Application.Transcript; namespace AgentCore.Application.Conversation @@ -7,13 +8,13 @@ namespace AgentCore.Application.Conversation /// The row. /// The stored messages, oldest first: every one, or one window's worth. /// - /// One link per file the messages name and the blob store kept, each name once, in first-seen order. - /// The links expire; read them out at once rather than keeping this record around. + /// The facts of each file the messages name and the blob store kept, each name once, in first-seen order. + /// A link to the bytes comes from when the person asks for one. /// public sealed record StoredConversation( ConversationRecord Conversation, IReadOnlyList Messages, - IReadOnlyList Files) + IReadOnlyList Files) { /// /// Gets the turn to read the next older window before, or when diff --git a/src/AgentCore.Application/Ports/IConversations.cs b/src/AgentCore.Application/Ports/IConversations.cs index 89ec85af..f91b47a3 100644 --- a/src/AgentCore.Application/Ports/IConversations.cs +++ b/src/AgentCore.Application/Ports/IConversations.cs @@ -1,3 +1,4 @@ +using AgentCore.Application.Blobs; using System.Text.Json; using AgentCore.Application.Conversation; using AgentCore.Application.Transcript; @@ -16,7 +17,7 @@ public interface IConversations /// ValueTask GetAsync(string conversationId, CancellationToken cancellationToken = default); - /// Reads one window of a conversation: its row, the newest turns before a given one, and a link to every file those turns kept. + /// Reads one window of a conversation: its row, the newest turns before a given one, and the facts of every file those turns kept. /// The conversation to read. /// Which turns: how many, and before which. /// Cancels the read. @@ -26,6 +27,19 @@ public interface IConversations /// ValueTask LoadWindowAsync(string conversationId, TranscriptWindow window, CancellationToken cancellationToken = default); + /// + /// Makes a fresh, short-lived link to one file a conversation kept. Call it when the person asks for the file. + /// A host that proxies the bytes itself, or needs a different lifetime, goes through instead. + /// + /// The conversation that owns the file. The caller has already checked who may read it. + /// The file's name, as lists it. + /// Cancels the lookup and the signing. + /// + /// The URL, or when the host opened no blob store, the conversation id fails + /// or the name fails (blank included; this never throws for either), the store holds no blob under that owner and name, or the store has no web door. + /// + ValueTask LinkFileAsync(string conversationId, string name, CancellationToken cancellationToken = default); + /// ValueTask ListAsync( string principalKey, diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt index e7934d94..7fe2746d 100644 --- a/src/AgentCore.Application/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt @@ -2032,15 +2032,6 @@ static AgentCore.Application.Blobs.BlobLink.Lifetime.get -> System.TimeSpan static AgentCore.Application.Blobs.BlobLink.DispositionOf(string! mediaType, string! name) -> string! AgentCore.Application.Ports.IBlobStore.LinkAsync(AgentCore.Application.Blobs.BlobRef! blob, System.TimeSpan lifetime, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IBlobStore.StatAsync(string! ownerId, string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Blobs.FileLink -AgentCore.Application.Blobs.FileLink.$() -> AgentCore.Application.Blobs.FileLink! -AgentCore.Application.Blobs.FileLink.Blob.get -> AgentCore.Application.Blobs.BlobRef! -AgentCore.Application.Blobs.FileLink.Blob.init -> void -AgentCore.Application.Blobs.FileLink.Deconstruct(out AgentCore.Application.Blobs.BlobRef! Blob, out System.Uri? Url) -> void -AgentCore.Application.Blobs.FileLink.Equals(AgentCore.Application.Blobs.FileLink? other) -> bool -AgentCore.Application.Blobs.FileLink.FileLink(AgentCore.Application.Blobs.BlobRef! Blob, System.Uri? Url) -> void -AgentCore.Application.Blobs.FileLink.Url.get -> System.Uri? -AgentCore.Application.Blobs.FileLink.Url.init -> void AgentCore.Application.Conversation.Conversations AgentCore.Application.Conversation.Conversations.AppendAsync(string! conversationId, System.Collections.Generic.IReadOnlyList! messages, AgentCore.Application.Conversation.ConversationSessionState? state = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> AgentCore.Application.Conversation.Conversations.SaveStateAsync(string! conversationId, AgentCore.Application.Conversation.ConversationSessionState! state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -2054,7 +2045,6 @@ AgentCore.Application.Conversation.Conversations.DetachPrincipalAsync(string! co AgentCore.Application.Conversation.Conversations.EraseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.GetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.FindContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Conversation.Conversations.LinkFilesAsync(string! conversationId, System.Collections.Generic.IEnumerable! messages, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! AgentCore.Application.Conversation.Conversations.ListAsync(string! principalKey, string? after, int limit, AgentCore.Application.Conversation.ConversationStatus? status = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.RenameAsync(string! conversationId, string! title, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.RewriteAsync(string! conversationId, string! messageId, Microsoft.Extensions.AI.ChatMessage! content, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -2063,11 +2053,6 @@ AgentCore.Application.Conversation.Conversations.SetCustomAsync(string! conversa AgentCore.Application.Conversation.Conversations.SetExternalIdAsync(string! conversationId, string? externalId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.SetStatusAsync(string! conversationId, AgentCore.Application.Conversation.ConversationStatus status, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.SweepAsync(System.TimeSpan retention, int batchSize = 500, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -override AgentCore.Application.Blobs.FileLink.Equals(object? obj) -> bool -override AgentCore.Application.Blobs.FileLink.GetHashCode() -> int -override AgentCore.Application.Blobs.FileLink.ToString() -> string! -static AgentCore.Application.Blobs.FileLink.operator ==(AgentCore.Application.Blobs.FileLink? left, AgentCore.Application.Blobs.FileLink? right) -> bool -static AgentCore.Application.Blobs.FileLink.operator !=(AgentCore.Application.Blobs.FileLink? left, AgentCore.Application.Blobs.FileLink? right) -> bool AgentCore.Application.Conversation.Conversations.Store.get -> AgentCore.Application.Ports.IConversationStore! AgentCore.Application.Transcript.FileContent AgentCore.Application.Transcript.FileContent.FileId.get -> string! @@ -2118,11 +2103,9 @@ AgentCore.Application.Configuration.Compilation.AgentCompilationContext.Hooks.in AgentCore.Application.Conversation.StoredConversation AgentCore.Application.Conversation.StoredConversation.Conversation.get -> AgentCore.Application.Conversation.ConversationRecord! AgentCore.Application.Conversation.StoredConversation.Conversation.init -> void -AgentCore.Application.Conversation.StoredConversation.Files.get -> System.Collections.Generic.IReadOnlyList! AgentCore.Application.Conversation.StoredConversation.Files.init -> void AgentCore.Application.Conversation.StoredConversation.Messages.get -> System.Collections.Generic.IReadOnlyList! AgentCore.Application.Conversation.StoredConversation.Messages.init -> void -AgentCore.Application.Conversation.StoredConversation.StoredConversation(AgentCore.Application.Conversation.ConversationRecord! Conversation, System.Collections.Generic.IReadOnlyList! Messages, System.Collections.Generic.IReadOnlyList! Files) -> void AgentCore.Application.Ports.IConversations AgentCore.Application.Ports.IConversations.AppendMessageAsync(string! conversationId, Microsoft.Extensions.AI.ChatMessage! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversations.AttachPrincipalAsync(string! conversationId, string! principalKey, string! role, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -2137,7 +2120,6 @@ AgentCore.Application.Ports.IConversations.SetCustomAsync(string! conversationId AgentCore.Application.Ports.IConversations.SetExternalIdAsync(string! conversationId, string? externalId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversations.SetStatusAsync(string! conversationId, AgentCore.Application.Conversation.ConversationStatus status, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.StoredConversation.$() -> AgentCore.Application.Conversation.StoredConversation! -AgentCore.Application.Conversation.StoredConversation.Deconstruct(out AgentCore.Application.Conversation.ConversationRecord! Conversation, out System.Collections.Generic.IReadOnlyList! Messages, out System.Collections.Generic.IReadOnlyList! Files) -> void AgentCore.Application.Conversation.StoredConversation.Equals(AgentCore.Application.Conversation.StoredConversation? other) -> bool override AgentCore.Application.Conversation.StoredConversation.Equals(object? obj) -> bool override AgentCore.Application.Conversation.StoredConversation.GetHashCode() -> int @@ -3192,3 +3174,10 @@ static AgentCore.Application.Conversation.Commands.TransferCommand.operator !=(A virtual AgentCore.Application.Conversation.Commands.ChannelCommand.EqualityContract.get -> System.Type! virtual AgentCore.Application.Conversation.Commands.ChannelCommand.Equals(AgentCore.Application.Conversation.Commands.ChannelCommand? other) -> bool virtual AgentCore.Application.Conversation.Commands.ChannelCommand.PrintMembers(System.Text.StringBuilder! builder) -> bool +AgentCore.Application.Conversation.Conversations.LinkFileAsync(string! conversationId, string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.StoredConversation.Deconstruct(out AgentCore.Application.Conversation.ConversationRecord! Conversation, out System.Collections.Generic.IReadOnlyList! Messages, out System.Collections.Generic.IReadOnlyList! Files) -> void +AgentCore.Application.Conversation.StoredConversation.Files.get -> System.Collections.Generic.IReadOnlyList! +AgentCore.Application.Conversation.StoredConversation.StoredConversation(AgentCore.Application.Conversation.ConversationRecord! Conversation, System.Collections.Generic.IReadOnlyList! Messages, System.Collections.Generic.IReadOnlyList! Files) -> void +AgentCore.Application.Ports.IConversations.LinkFileAsync(string! conversationId, string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Blobs.BlobOwner +static AgentCore.Application.Blobs.BlobOwner.IsSafe(string? ownerId) -> bool diff --git a/src/AgentCore.Application/Tools/Builtin/FilePublishTool.cs b/src/AgentCore.Application/Tools/Builtin/FilePublishTool.cs index bb867557..24bc92c8 100644 --- a/src/AgentCore.Application/Tools/Builtin/FilePublishTool.cs +++ b/src/AgentCore.Application/Tools/Builtin/FilePublishTool.cs @@ -16,7 +16,8 @@ namespace AgentCore.Application.Tools.Builtin { /// /// Copies one file out of the running conversation's workspace into the blob store, owned by the conversation, and - /// files a on the turn so the card reaches the person. + /// files a on the turn so the card reaches the person. The model gets a stable + /// sandbox:/ name to link the file by. /// internal sealed class FilePublishTool { @@ -104,7 +105,6 @@ private async ValueTask PublishAsync( } BlobRef blob; - Uri? url; try { @@ -113,8 +113,6 @@ private async ValueTask PublishAsync( blob = await _blobs .PutAsync(new BlobWrite(conversationId, name, BlobMediaTypes.Of(name), content, length), cancellationToken) .ConfigureAwait(false); - - url = await _blobs.LinkAsync(blob, BlobLink.Lifetime, cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { @@ -143,7 +141,7 @@ private async ValueTask PublishAsync( ["name"] = blob.Name, ["mediaType"] = blob.MediaType, ["length"] = blob.Length, - ["url"] = url?.ToString(), + ["link"] = SandboxLink(blob.Name), }; } @@ -184,6 +182,12 @@ private async ValueTask PublishAsync( : null; } + /// The stable name the model writes into its reply: sandbox:/ and the stored name, escaped so markdown and a URL decoder read it back whole. + private static string SandboxLink(string name) + { + return "sandbox:/" + Uri.EscapeDataString(name); + } + private JsonObject Failed(string message) { return ToolErrorResult.Create(_tool.Id, message); diff --git a/src/AgentCore.Application/Tools/Builtin/FilePublishToolDefinition.cs b/src/AgentCore.Application/Tools/Builtin/FilePublishToolDefinition.cs index b40bbcd0..225b0aaf 100644 --- a/src/AgentCore.Application/Tools/Builtin/FilePublishToolDefinition.cs +++ b/src/AgentCore.Application/Tools/Builtin/FilePublishToolDefinition.cs @@ -17,8 +17,11 @@ internal sealed class FilePublishToolDefinition : IBuiltinToolDefinition /// public string DefaultDescription => - "Hands one file from the workspace to the person and returns a link to it. " - + "A file that is not published is lost when the conversation ends."; + "Hands one file from the workspace to the person. " + + "A file that is not published is lost when the conversation ends. " + + "The result has a `link`. Link the file in your reply like [Sales by month](sandbox:/sales.csv), " + + "copying the `link` value exactly, with no angle brackets or other changes. Never write any other address for the file. " + + "Give each file a distinct name: a second file with the same name replaces the first."; /// public AITool Build(ToolConfiguration tool, BuiltinToolPorts ports) diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs index ef4926fb..42d490bc 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs @@ -230,9 +230,6 @@ internal sealed record FilePayload /// Gets how many bytes it holds. public required long Length { get; init; } - - /// Gets the link to fetch it from, or when the store has no web door. - public string? Url { get; init; } } /// The ids one turn's commit wrote, as the browser reads it. diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs index 765e137b..f571601a 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs @@ -149,11 +149,10 @@ private static async IAsyncEnumerable StreamAgentUpdatesAsy if (dialect) { - // The enumeration above ends only after the run has finished, and the publish tool ran - // inside it: by here the bytes are in the store or never will be. + // The enumeration above ends only after the run has finished, so every file the turn published is noted. Conversations conversations = http.RequestServices.GetRequiredService(); - await foreach (TurnStreamPart part in files.ResolveAsync(conversations, turn.Conversation.ConversationId, cancellationToken).ConfigureAwait(false)) + foreach (TurnStreamPart part in files.Resolve(conversations, turn.Conversation.ConversationId)) { await WritePartLineAsync(http, part, cancellationToken).ConfigureAwait(false); } diff --git a/src/AgentCore.AspNetCore/Endpoints/TurnStreamFiles.cs b/src/AgentCore.AspNetCore/Endpoints/TurnStreamFiles.cs index ba030a51..d25e6fbb 100644 --- a/src/AgentCore.AspNetCore/Endpoints/TurnStreamFiles.cs +++ b/src/AgentCore.AspNetCore/Endpoints/TurnStreamFiles.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Transcript; -using System.Runtime.CompilerServices; using AgentCore.Application.Conversation; using Microsoft.Extensions.AI; using AgentCore.Application.Blobs; @@ -7,7 +6,7 @@ namespace AgentCore.AspNetCore.Endpoints { /// - /// The files one turn published, linked for the browser once the turn is over. + /// The files one turn published, listed for the browser once the turn is over. /// internal sealed class TurnStreamFiles { @@ -20,26 +19,18 @@ internal void Note(ChatResponseUpdate update) _files.AddRange(update.Contents.OfType()); } - /// Links every noted file the store kept, in the order they were noted. + /// Lists every noted file the store kept, in the order they were noted. /// The door to the stored conversation. /// The conversation that owns the files. - /// Cancels the lookups. /// One part for each file the store holds. - internal async IAsyncEnumerable ResolveAsync( - Conversations conversations, - string conversationId, - [EnumeratorCancellation] CancellationToken cancellationToken) + internal IEnumerable Resolve(Conversations conversations, string conversationId) { if (_files.Count == 0) { yield break; } - IReadOnlyList links = await conversations - .LinkFilesAsync(conversationId, [new ChatMessage(ChatRole.Assistant, [.. _files])], cancellationToken) - .ConfigureAwait(false); - - foreach ((BlobRef? blob, Uri? url) in links) + foreach (BlobRef blob in conversations.KeptFiles(conversationId, [new ChatMessage(ChatRole.Assistant, [.. _files])])) { yield return new TurnStreamPart(TurnStreamPart.File, new FilePayload { @@ -47,7 +38,6 @@ internal async IAsyncEnumerable ResolveAsync( Title = _files.LastOrDefault(file => string.Equals(file.Name, blob.Name, StringComparison.Ordinal))?.Title, MediaType = blob.MediaType, Length = blob.Length, - Url = url?.ToString(), }); } } diff --git a/src/AgentCore.Infrastructure/Blobs/S3/S3BlobStore.cs b/src/AgentCore.Infrastructure/Blobs/S3/S3BlobStore.cs index f851a125..d2f57174 100644 --- a/src/AgentCore.Infrastructure/Blobs/S3/S3BlobStore.cs +++ b/src/AgentCore.Infrastructure/Blobs/S3/S3BlobStore.cs @@ -189,10 +189,8 @@ private static string KeyOf(string ownerId, string name) private static string OwnerPrefix(string ownerId) { - ArgumentException.ThrowIfNullOrWhiteSpace(ownerId); - - return ownerId.Contains('/', StringComparison.Ordinal) - ? throw new ArgumentException("An owner id may not hold a slash.", nameof(ownerId)) + return !BlobOwner.IsSafe(ownerId) + ? throw new ArgumentException("An owner id must pass BlobOwner.IsSafe.", nameof(ownerId)) : ownerId + "/"; } } diff --git a/tests/AgentCore.Application.Tests/Blobs/BlobLinkTests.cs b/tests/AgentCore.Application.Tests/Blobs/BlobLinkTests.cs index 80904407..0a7bec18 100644 --- a/tests/AgentCore.Application.Tests/Blobs/BlobLinkTests.cs +++ b/tests/AgentCore.Application.Tests/Blobs/BlobLinkTests.cs @@ -33,10 +33,10 @@ public void DispositionOf_NameWithSpaceAndUnicode_PercentEncodesPerRfc5987() } [Fact] - public void Lifetime_IsFifteenMinutes() + public void Lifetime_IsFiveMinutes() { - // Assert: decision 10 of the sandbox file capture spec. - Assert.Equal(TimeSpan.FromMinutes(15), BlobLink.Lifetime); + // Assert: the click-time file links spec. + Assert.Equal(TimeSpan.FromMinutes(5), BlobLink.Lifetime); } } } diff --git a/tests/AgentCore.Application.Tests/Conversation/ConversationsTests.cs b/tests/AgentCore.Application.Tests/Conversation/ConversationsTests.cs index dc725be2..dbef9396 100644 --- a/tests/AgentCore.Application.Tests/Conversation/ConversationsTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/ConversationsTests.cs @@ -70,7 +70,7 @@ public async Task DeleteAsync_NoBlobStore_DeletesTheRowAlone() } [Fact] - public async Task LoadWindowAsync_ReturnsTheRowTheWordsAndOneLinkPerKeptFile() + public async Task LoadWindowAsync_ReturnsTheRowTheWordsAndTheFactsOfEachKeptFile() { // Arrange CountingBlobStore blobs = new(); @@ -87,13 +87,11 @@ public async Task LoadWindowAsync_ReturnsTheRowTheWordsAndOneLinkPerKeptFile() Assert.NotNull(stored); Assert.Equal("conversation-1", stored.Conversation.ConversationId); Assert.Equal(["chart it", "see"], stored.Messages.Select(message => message.Content.Text)); - FileLink link = Assert.Single(stored.Files); - Assert.Equal(new BlobRef("conversation-1", "chart.png", "image/png", 3), link.Blob); - Assert.Equal("https://blobs.test/conversation-1/chart.png?ttl=900", link.Url?.ToString()); + Assert.Equal(new BlobRef("conversation-1", "chart.png", "image/png", 3), Assert.Single(stored.Files)); } [Fact] - public async Task LoadWindowAsync_AFullWindow_NamesTheTurnToReadBeforeNext_AndLinksThatWindowsFilesAlone() + public async Task LoadWindowAsync_AFullWindow_NamesTheTurnToReadBeforeNext_AndListsThatWindowsFilesAlone() { // Arrange: turn 0 kept a file, turn 1 kept another. The window holds turn 1 only. CountingBlobStore blobs = new(); @@ -117,7 +115,7 @@ public async Task LoadWindowAsync_AFullWindow_NamesTheTurnToReadBeforeNext_AndLi Assert.NotNull(stored); Assert.Equal(["m2", "m3"], stored.Messages.Select(message => message.MessageId)); Assert.Equal(1, stored.OlderBefore); - Assert.Equal("rows.csv", Assert.Single(stored.Files).Blob.Name); + Assert.Equal("rows.csv", Assert.Single(stored.Files).Name); } [Fact] @@ -152,83 +150,192 @@ public async Task LoadWindowAsync_AConversationThatWasNeverMade_IsNull() } [Fact] - public async Task LinkFilesAsync_LinksFromTheContent_EachOnce_InFirstSeenOrder_WithoutAskingTheStore() + public void KeptFiles_ListsFromTheContent_EachOnce_InFirstSeenOrder_WithoutAskingTheStore() { // Arrange: the store is empty on purpose. The content is the index. CountingBlobStore blobs = new(); Conversations conversations = new(new InMemoryConversationStore(), blobs); // Act - IReadOnlyList links = await conversations.LinkFilesAsync( + IReadOnlyList files = conversations.KeptFiles( "conversation-1", [ new ChatMessage(ChatRole.Assistant, [Kept("chart.png", "image/png"), Refused("lost.pdf")]), ReplyWith(("rows.csv", "text/csv"), ("chart.png", "image/png")), - ], - TestContext.Current.CancellationToken); + ]); // Assert - Assert.Collection( - links, - link => - { - Assert.Equal(new BlobRef("conversation-1", "chart.png", "image/png", 3), link.Blob); - Assert.Equal("https://blobs.test/conversation-1/chart.png?ttl=900", link.Url?.ToString()); - }, - link => Assert.Equal("rows.csv", link.Blob.Name)); + Assert.Equal( + [new BlobRef("conversation-1", "chart.png", "image/png", 3), new BlobRef("conversation-1", "rows.csv", "text/csv", 3)], + files); Assert.Equal(0, blobs.Stats); } [Fact] - public async Task LinkFilesAsync_NoBlobStore_LinksNothing() + public void KeptFiles_NoBlobStore_ListsNothing() { // Arrange Conversations conversations = new(new InMemoryConversationStore(), blobs: null); // Act - IReadOnlyList links = await conversations.LinkFilesAsync("conversation-1", [ReplyWith(("chart.png", "image/png"))], TestContext.Current.CancellationToken); + IReadOnlyList files = conversations.KeptFiles("conversation-1", [ReplyWith(("chart.png", "image/png"))]); // Assert - Assert.Empty(links); + Assert.Empty(files); } [Fact] - public async Task LinkFilesAsync_SameNameKeptTwice_TheLaterFactsWinInTheFirstPlace() + public void KeptFiles_SameNameKeptTwice_TheLaterFactsWinInTheFirstPlace() { // Arrange: the model redrew chart.png after rows.csv; the store replaced the first. Conversations conversations = new(new InMemoryConversationStore(), new RecordingBlobStore()); ChatMessage reply = new(ChatRole.Assistant, [Kept("chart.png", "image/png", 10), Kept("rows.csv", "text/csv", 5), Kept("chart.png", "image/png", 20)]); // Act - IReadOnlyList links = await conversations.LinkFilesAsync("conversation-1", [reply], TestContext.Current.CancellationToken); + IReadOnlyList files = conversations.KeptFiles("conversation-1", [reply]); // Assert Assert.Equal( [new BlobRef("conversation-1", "chart.png", "image/png", 20), new BlobRef("conversation-1", "rows.csv", "text/csv", 5)], - links.Select(link => link.Blob)); + files); } [Theory] [InlineData("../etc/passwd")] [InlineData("a/b.png")] [InlineData("")] - public async Task LinkFilesAsync_UnsafeName_IsNotLinked(string name) + public void KeptFiles_UnsafeName_IsNotListed(string name) { // Arrange Conversations conversations = new(new InMemoryConversationStore(), new RecordingBlobStore()); // Act - IReadOnlyList links = await conversations.LinkFilesAsync("conversation-1", [new ChatMessage(ChatRole.Assistant, [Kept(name, "image/png")])], TestContext.Current.CancellationToken); + IReadOnlyList files = conversations.KeptFiles("conversation-1", [new ChatMessage(ChatRole.Assistant, [Kept(name, "image/png")])]); + + // Assert + Assert.Empty(files); + } + + [Fact] + public async Task LinkFileAsync_AKeptBlob_ReturnsALinkMintedForFiveMinutes() + { + // Arrange + RecordingBlobStore blobs = new(); + Conversations conversations = new(new InMemoryConversationStore(), blobs); + _ = await KeepAsync(blobs, "conversation-1", "chart.png", "image/png"); + + // Act + Uri? url = await conversations.LinkFileAsync("conversation-1", "chart.png", TestContext.Current.CancellationToken); + + // Assert: the lifetime is the owner's. + Assert.Equal("https://blobs.test/conversation-1/chart.png?ttl=300", url?.ToString()); + } + + [Fact] + public async Task LinkFileAsync_ABlobAnotherConversationKept_IsNull() + { + // Arrange + RecordingBlobStore blobs = new(); + Conversations conversations = new(new InMemoryConversationStore(), blobs); + _ = await KeepAsync(blobs, "conversation-2", "chart.png", "image/png"); + + // Act + Uri? url = await conversations.LinkFileAsync("conversation-1", "chart.png", TestContext.Current.CancellationToken); + + // Assert + Assert.Null(url); + } + + [Fact] + public async Task LinkFileAsync_NoSuchBlob_IsNullAndNothingIsSigned() + { + // Arrange + CountingBlobStore blobs = new(); + Conversations conversations = new(new InMemoryConversationStore(), blobs); + + // Act + Uri? url = await conversations.LinkFileAsync("conversation-1", "chart.png", TestContext.Current.CancellationToken); + + // Assert + Assert.Null(url); + Assert.Equal(1, blobs.Stats); + Assert.Equal(0, blobs.Signed); + } + + [Theory] + [InlineData("../etc/passwd")] + [InlineData("a/b.png")] + [InlineData("")] + public async Task LinkFileAsync_UnsafeName_IsNullAndTheStoreIsNotAsked(string name) + { + // Arrange + CountingBlobStore blobs = new(); + Conversations conversations = new(new InMemoryConversationStore(), blobs); + + // Act + Uri? url = await conversations.LinkFileAsync("conversation-1", name, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(url); + Assert.Equal(0, blobs.Stats); + } + + [Fact] + public async Task LinkFileAsync_BlobExistsButTheStoreHasNoWebDoor_IsNull() + { + // Arrange + CountingBlobStore blobs = new() { Holds = new BlobRef("conversation-1", "chart.png", "image/png", 3), WebDoor = false }; + Conversations conversations = new(new InMemoryConversationStore(), blobs); + + // Act + Uri? url = await conversations.LinkFileAsync("conversation-1", "chart.png", TestContext.Current.CancellationToken); // Assert - Assert.Empty(links); + Assert.Null(url); } - /// Links like the recording store, and counts how often anyone asks it whether a blob exists. + [Theory] + [InlineData("a/b")] + [InlineData("")] + [InlineData(" ")] + public async Task LinkFileAsync_AConversationIdThatCannotBeAKey_IsNullAndTheStoreIsNotAsked(string conversationId) + { + // Arrange + CountingBlobStore blobs = new(); + Conversations conversations = new(new InMemoryConversationStore(), blobs); + + // Act + Uri? url = await conversations.LinkFileAsync(conversationId, "chart.png", TestContext.Current.CancellationToken); + + // Assert + Assert.Null(url); + Assert.Equal(0, blobs.Stats); + } + + [Fact] + public async Task LinkFileAsync_NoBlobStore_IsNull() + { + // Arrange + Conversations conversations = new(new InMemoryConversationStore(), blobs: null); + + // Act + Uri? url = await conversations.LinkFileAsync("conversation-1", "chart.png", TestContext.Current.CancellationToken); + + // Assert + Assert.Null(url); + } + + /// Counts how often anyone asks it whether a blob exists and how often anyone signs one. It holds one blob at most, and can lack a web door. private sealed class CountingBlobStore : IBlobStore { public int Stats { get; private set; } + public int Signed { get; private set; } + + public BlobRef? Holds { get; init; } + + public bool WebDoor { get; init; } = true; + public ValueTask PutAsync(BlobWrite write, CancellationToken cancellationToken = default) { throw new NotSupportedException(); @@ -242,12 +349,13 @@ public ValueTask PutAsync(BlobWrite write, CancellationToken cancellati public ValueTask StatAsync(string ownerId, string name, CancellationToken cancellationToken = default) { Stats++; - return ValueTask.FromResult(null); + return ValueTask.FromResult(Holds); } public ValueTask LinkAsync(BlobRef blob, TimeSpan lifetime, CancellationToken cancellationToken = default) { - return ValueTask.FromResult(new Uri($"https://blobs.test/{blob.OwnerId}/{blob.Name}?ttl={(int)lifetime.TotalSeconds}")); + Signed++; + return ValueTask.FromResult(!WebDoor ? null : new Uri($"https://blobs.test/{blob.OwnerId}/{blob.Name}?ttl={(int)lifetime.TotalSeconds}")); } public ValueTask DeleteByOwnerAsync(string ownerId, CancellationToken cancellationToken = default) diff --git a/tests/AgentCore.Application.Tests/Tools/FilePublishBackgroundChildTests.cs b/tests/AgentCore.Application.Tests/Tools/FilePublishBackgroundChildTests.cs index 3f1da598..b0dbbb97 100644 --- a/tests/AgentCore.Application.Tests/Tools/FilePublishBackgroundChildTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/FilePublishBackgroundChildTests.cs @@ -55,7 +55,7 @@ public async Task AChildThatPublishes_StoresTheFileUnderTheParentConversation() Assert.Equal(["publish"], childModel.Called); string result = Assert.Single(childModel.ToolResults); - Assert.Contains("https://blobs.test/conversation-9/rows.csv", result, StringComparison.Ordinal); + Assert.Contains("sandbox:/rows.csv", result, StringComparison.Ordinal); Assert.DoesNotContain(ToolErrorResult.ErrorProperty + "\":true", result, StringComparison.Ordinal); _ = Assert.Single(blobs.Blobs.Keys, key => key == ("conversation-9", "rows.csv")); } diff --git a/tests/AgentCore.Application.Tests/Tools/FilePublishGraphParticipantTests.cs b/tests/AgentCore.Application.Tests/Tools/FilePublishGraphParticipantTests.cs index 1e61988c..56b0805e 100644 --- a/tests/AgentCore.Application.Tests/Tools/FilePublishGraphParticipantTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/FilePublishGraphParticipantTests.cs @@ -126,7 +126,7 @@ public async Task AFilePublishedInAGraphTurnIsInTheStoredFileListAndItsCardSurvi .LoadWindowAsync("conversation-1", new TranscriptWindow(null, 10), Ct); Assert.NotNull(stored); - Assert.Equal(["rows.csv"], stored.Files.Select(static link => link.Blob.Name)); + Assert.Equal(["rows.csv"], stored.Files.Select(static file => file.Name)); Assert.Equal( ["rows.csv"], stored.Messages.SelectMany(static message => message.Content.Contents).OfType().Select(static file => file.Name)); diff --git a/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs b/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs index d77fffbb..f7bf27e8 100644 --- a/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs @@ -125,19 +125,33 @@ public async Task Publish_AFileOverTheCap_IsRefusedWithTheReason() Assert.Contains("size 64 is outside 0..32 bytes", result["message"]!.GetValue(), StringComparison.Ordinal); } + [Theory] + [InlineData("out/a/report.csv", "sandbox:/report.csv")] + [InlineData("out/b/report.csv", "sandbox:/report.csv")] + [InlineData("out/Q3 report (final).csv", "sandbox:/Q3%20report%20%28final%29.csv")] + [InlineData("out/100%.csv", "sandbox:/100%25.csv")] + public async Task Publish_AFileInAFolder_LinksByTheStoredNameEscaped(string path, string expected) + { + string full = Path.Combine(_root, "conversation-1", path); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, "a\n"); + + JsonObject result = await PublishAsync(path); + + Assert.Equal(expected, result["link"]!.GetValue()); + } + [Fact] - public async Task Publish_AGoodFile_StoresItUnderTheConversationLinksItAndFilesTheCardOnTheTurn() + public async Task Publish_AGoodFile_StoresItUnderTheConversationNamesItBySandboxLinkAndFilesTheCardOnTheTurn() { File.WriteAllText(Path.Combine(_root, "conversation-1", "rows.csv"), "a,b\n1,2\n"); TurnFiles files = new(); JsonObject result = await PublishAsync("rows.csv", title: "Sales by month", files: files); - Assert.False(ToolErrorResult.IsError(result)); - Assert.Equal("rows.csv", result["name"]!.GetValue()); - Assert.Equal("text/csv", result["mediaType"]!.GetValue()); - Assert.Equal(8, result["length"]!.GetValue()); - Assert.Equal("https://blobs.test/conversation-1/rows.csv?ttl=900", result["url"]!.GetValue()); + Assert.True(JsonNode.DeepEquals( + new JsonObject { ["name"] = "rows.csv", ["mediaType"] = "text/csv", ["length"] = 8, ["link"] = "sandbox:/rows.csv" }, + result)); (string? mediaType, byte[]? bytes) = _blobs.Blobs[("conversation-1", "rows.csv")]; Assert.Equal("text/csv", mediaType); diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/FileWireTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/FileWireTests.cs index a01eaf9e..0e933da1 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/FileWireTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/FileWireTests.cs @@ -84,14 +84,14 @@ public async Task AToolThatPublishes_ReachesTheBrowserAsAFilePartAndTheTranscrip Assert.Equal("Quarterly report", file.GetProperty("title").GetString()); Assert.Equal("text/csv", file.GetProperty("media_type").GetString()); Assert.Equal(19, file.GetProperty("length").GetInt64()); - Assert.Equal($"https://blobs.test/{ConversationId}/report.csv?ttl=900", file.GetProperty("url").GetString()); + Assert.Equal(["name", "title", "media_type", "length"], file.EnumerateObject().Select(property => property.Name)); // The bytes are in the store, owned by the conversation. (string? mediaType, byte[]? bytes) = blobs.Store.Blobs[(ConversationId, "report.csv")]; Assert.Equal("text/csv", mediaType); Assert.Equal("month,sales\njan,10\n", System.Text.Encoding.UTF8.GetString(bytes)); - // The model read the link back in its tool result. + // The model reads the sandbox name back in its tool result, never a storage URL. JsonElement toolResult = events .Select(text => JsonDocument.Parse(text).RootElement) .Where(chunk => chunk.TryGetProperty("agentcore_tool", out JsonElement tool) @@ -99,16 +99,19 @@ public async Task AToolThatPublishes_ReachesTheBrowserAsAFilePartAndTheTranscrip .Select(chunk => chunk.GetProperty("agentcore_tool")) .Single(); Assert.False(toolResult.GetProperty("failed").GetBoolean()); - Assert.Equal($"https://blobs.test/{ConversationId}/report.csv?ttl=900", toolResult.GetProperty("result").GetProperty("url").GetString()); + Assert.Equal( + ["name", "mediaType", "length", "link"], + toolResult.GetProperty("result").EnumerateObject().Select(property => property.Name)); + Assert.Equal("sandbox:/report.csv", toolResult.GetProperty("result").GetProperty("link").GetString()); - // And a later read of the transcript links the same file again. + // A later read of the transcript lists the same file as facts, and a click mints a fresh link. Conversations conversations = host.Services.GetRequiredService(); IReadOnlyList stored = await conversations.ReadAllAsync(ConversationId, TestContext.Current.CancellationToken); - IReadOnlyList links = await conversations.LinkFilesAsync(ConversationId, stored.Select(row => row.Content), TestContext.Current.CancellationToken); + BlobRef kept = Assert.Single(conversations.KeptFiles(ConversationId, stored.Select(row => row.Content))); + Assert.Equal((ConversationId, "report.csv", "text/csv", 19L), (kept.OwnerId, kept.Name, kept.MediaType, kept.Length)); - FileLink link = Assert.Single(links); - Assert.Equal((ConversationId, "report.csv", "text/csv", 19L), (link.Blob.OwnerId, link.Blob.Name, link.Blob.MediaType, link.Blob.Length)); - Assert.Equal($"https://blobs.test/{ConversationId}/report.csv?ttl=900", link.Url?.ToString()); + Uri? url = await conversations.LinkFileAsync(ConversationId, "report.csv", TestContext.Current.CancellationToken); + Assert.Equal($"https://blobs.test/{ConversationId}/report.csv?ttl=300", url?.ToString()); } [Fact] diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/TurnStreamFilesTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/TurnStreamFilesTests.cs index 471d02dc..28e740fb 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/TurnStreamFilesTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/TurnStreamFilesTests.cs @@ -9,7 +9,7 @@ namespace AgentCore.AspNetCore.Tests.Endpoints { - /// Which published files reach the browser as a part, and with what link. + /// Which published files reach the browser as a part, and with what facts. public sealed class TurnStreamFilesTests { private static ChatResponseUpdate UpdateWith(params AIContent[] contents) @@ -24,53 +24,40 @@ private static FileContent Kept(string name, string mediaType, long length) } [Fact] - public async Task ResolveAsync_NotedFileTheCaptureKept_YieldsOnePartWithTheLink() + public void Resolve_NotedFileTheCaptureKept_YieldsOnePartWithTheFacts() { // Arrange: the same content passes twice, as a re-yielded update would; a refused one passes too. - Conversations conversations = new(new InMemoryConversationStore(), new StubBlobStore()); + StubBlobStore blobs = new(); + Conversations conversations = new(new InMemoryConversationStore(), blobs); TurnStreamFiles files = new(); FileContent chart = Kept("chart.png", "image/png", 48213); + chart.Title = "Chart"; FileContent refused = new() { Name = "refused.png", FileId = "cfile_2" }; files.Note(UpdateWith(new TextContent("see"), chart)); files.Note(UpdateWith(chart)); files.Note(UpdateWith(refused)); // Act - List parts = await files.ResolveAsync(conversations, "conversation-1", TestContext.Current.CancellationToken).ToListAsync(TestContext.Current.CancellationToken); + List parts = [.. files.Resolve(conversations, "conversation-1")]; - // Assert: once, linked, and the refused file is not there. + // Assert: once, and the refused file is not there. TurnStreamPart part = Assert.Single(parts); Assert.Equal(TurnStreamPart.File, part.Member); FilePayload payload = Assert.IsType(part.Payload); Assert.Equal("chart.png", payload.Name); + Assert.Equal("Chart", payload.Title); Assert.Equal("image/png", payload.MediaType); Assert.Equal(48213, payload.Length); - Assert.Equal("https://blobs.test/conversation-1/chart.png?ttl=900", payload.Url); } [Fact] - public async Task ResolveAsync_StoreWithNoWebDoor_YieldsThePartWithoutAUrl() - { - // Arrange - Conversations conversations = new(new InMemoryConversationStore(), new StubBlobStore { Links = false }); - TurnStreamFiles files = new(); - files.Note(UpdateWith(Kept("rows.csv", "text/csv", 8))); - - // Act - List parts = await files.ResolveAsync(conversations, "conversation-1", TestContext.Current.CancellationToken).ToListAsync(TestContext.Current.CancellationToken); - - // Assert - Assert.Null(Assert.IsType(Assert.Single(parts).Payload).Url); - } - - [Fact] - public async Task ResolveAsync_NothingNoted_YieldsNothing() + public void Resolve_NothingNoted_YieldsNothing() { // Arrange Conversations conversations = new(new InMemoryConversationStore(), blobs: null); // Act - List parts = await new TurnStreamFiles().ResolveAsync(conversations, "conversation-1", TestContext.Current.CancellationToken).ToListAsync(TestContext.Current.CancellationToken); + List parts = [.. new TurnStreamFiles().Resolve(conversations, "conversation-1")]; // Assert Assert.Empty(parts); @@ -78,8 +65,6 @@ public async Task ResolveAsync_NothingNoted_YieldsNothing() private sealed class StubBlobStore : IBlobStore { - public bool Links { get; init; } = true; - public ValueTask PutAsync(BlobWrite write, CancellationToken cancellationToken = default) { throw new NotSupportedException(); @@ -92,12 +77,12 @@ public ValueTask PutAsync(BlobWrite write, CancellationToken cancellati public ValueTask StatAsync(string ownerId, string name, CancellationToken cancellationToken = default) { - throw new NotSupportedException("The read path links from the content and never asks the store."); + throw new NotSupportedException("Listing reads the content and never asks the store."); } public ValueTask LinkAsync(BlobRef blob, TimeSpan lifetime, CancellationToken cancellationToken = default) { - return ValueTask.FromResult(Links ? new Uri($"https://blobs.test/{blob.OwnerId}/{blob.Name}?ttl={(int)lifetime.TotalSeconds}") : null); + throw new NotSupportedException("Listing never signs a link."); } public ValueTask DeleteByOwnerAsync(string ownerId, CancellationToken cancellationToken = default) From ae4cb2d68ddd94578f61261d4b65729f0fde8b49 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Tue, 6 Oct 2026 14:23:37 -0600 Subject: [PATCH 2/3] chore: fix sonarqube linting --- .../Compilation/AgentToolCompiler.cs | 2 +- .../Configuration/Compilation/PolicyRow.cs | 2 +- src/AgentCore.Application/Hooks/AgentHook.cs | 210 ++++++++++++++---- .../Hooks/BuiltIn/AuditHook.cs | 36 ++- .../Hooks/BuiltIn/AuditRules.cs | 16 +- .../Hooks/BuiltIn/LoggingHook.cs | 10 +- .../Hooks/BuiltIn/TelemetryHook.cs | 36 ++- .../Hooks/BuiltIn/TurnFailureAccounting.cs | 15 +- .../Hooks/Engine/GateRunner.cs | 11 +- .../Hooks/Engine/HookReader.cs | 10 +- .../Hooks/Engine/HookRuntime.cs | 16 +- .../Hooks/Engine/HookScopes.cs | 11 +- .../Hooks/Engine/HookTable.cs | 38 +++- .../Hooks/Engine/NoticeHub.cs | 5 +- .../Hooks/Engine/SessionHooks.cs | 21 +- .../Hooks/Engine/TurnRounds.cs | 5 +- .../Hooks/Layers/ApprovalGateLayer.cs | 10 +- .../Hooks/Layers/ModelHookChatClient.cs | 6 +- .../Hooks/Layers/ModelRoundGates.cs | 5 +- .../Hooks/Layers/ToolCallOutcomes.cs | 12 +- .../Hooks/Layers/ToolHookMiddleware.cs | 12 +- .../Hooks/Layers/TurnGateAgent.cs | 6 +- .../Llm/CompositeChatClientFactory.cs | 2 +- .../Runtime/Compaction/NoticingChatClient.cs | 10 +- .../Runtime/Compaction/SummaryRowProvider.cs | 12 +- .../Runtime/Cut/ConversationCutTracker.cs | 8 +- .../Harness/BackgroundSessionRelease.cs | 2 +- .../Runtime/Session/ChannelCommands.cs | 2 +- .../Runtime/Session/ConversationBusyMark.cs | 2 +- .../Runtime/Session/ConversationEnding.cs | 1 + .../Runtime/Session/ConversationStoreReads.cs | 7 +- .../AuditingFunctionInvokingChatClient.cs | 7 +- .../Runtime/ToolCalls/ConversationToolRuns.cs | 12 +- .../Runtime/Turn/ConversationTurn.cs | 1 - .../Turn/Lifecycle/ConversationTurnCommit.cs | 1 - .../Lifecycle/ConversationTurnCompletion.cs | 1 - .../Turn/Lifecycle/ConversationTurnStream.cs | 10 +- .../Runtime/Turn/Lifecycle/TurnDisposition.cs | 1 - .../Turn/Lifecycle/TurnReplyResolution.cs | 2 - .../Runtime/Turn/TurnMessages.cs | 2 +- .../Runtime/Turn/TurnUpdateMerge.cs | 15 +- .../Memory/InMemoryConversationSessions.cs | 2 +- .../Sessions/Memory/WorkspaceRootSweeper.cs | 4 +- .../Transcript/FrontVoice.cs | 11 +- src/AgentCore.AspNetCore/Calls/CallAnswer.cs | 10 +- src/AgentCore.AspNetCore/Calls/CallAsks.cs | 5 +- .../Calls/PhoneCallAdmission.cs | 15 +- .../DependencyInjection/AgentCoreBoot.cs | 9 +- .../AgentCoreHookOptions.cs | 6 +- .../Startup/ConversationSeamStartup.cs | 1 - .../Startup/FillerKeyCheck.cs | 13 +- .../Endpoints/ResponsesTurnConflict.cs | 6 +- .../Vendors/OpenAiLive/Call/LiveCallEnd.cs | 11 +- .../Vendors/OpenAiLive/Call/LiveChannel.cs | 5 +- .../Vendors/OpenAiLive/Call/LiveHearing.cs | 10 +- .../Vendors/OpenAiLive/OpenAiLiveCalls.cs | 4 +- .../Vendors/OpenAiLive/OpenAiLiveSettings.cs | 2 +- .../OpenAiLive/Webhook/OpenAiLiveWebhook.cs | 2 +- .../OpenAiLive/Wire/LiveEventReader.cs | 12 +- .../OpenAiLive/Wire/OpenAiLiveControl.cs | 28 ++- .../Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs | 103 ++++++--- .../Connection/TelnyxRelayConnection.cs | 25 ++- .../Connection/TelnyxRelayInput.cs | 4 +- .../Voice/Filler/ToolFillerScope.cs | 15 +- .../Voice/Session/VoiceNotices.cs | 35 +-- .../Voice/Speech/Replies/EngineReplyStream.cs | 2 +- .../Voice/Threading/AsyncEvent.cs | 2 +- .../Voice/Threading/TaskTeardown.cs | 2 +- .../Voice/Turns/UserTurnHandler.cs | 1 + .../Voice/Turns/VoiceConversationLoop.cs | 2 - ...ationSessionAuditConversationEndedTests.cs | 2 +- .../ConversationSessionAuditEventsTests.cs | 1 - .../ConversationSessionAuditSinkTests.cs | 1 - ...onversationSessionAuditToolFailureTests.cs | 1 - ...rsationSessionMultiStepAuditEventsTests.cs | 1 - .../Audit/QueuedAuditSinkRetryTests.cs | 2 +- .../Conversation/ChannelCommandsTests.cs | 2 +- ...emoryConversationStoreTurnConflictTests.cs | 2 +- .../Diagnostics/TurnLogOnceTests.cs | 1 - .../Diagnostics/TurnMetricsTests.cs | 1 - .../Diagnostics/TurnObservabilityHarness.cs | 1 - .../Diagnostics/TurnSpanTests.cs | 1 - .../Fakes/HoldingConversationStore.cs | 4 +- .../Hooks/ApprovalAnswerNoticeTests.cs | 1 - .../Hooks/ApprovalGateTests.cs | 10 +- .../Hooks/ApprovalTools.cs | 10 +- .../Hooks/AuditHookTests.cs | 7 +- .../Hooks/CallBriefHookTests.cs | 5 +- .../Hooks/CompactionGateTests.cs | 5 +- .../Hooks/FlakyModel.cs | 6 +- .../Hooks/GateOverheadTests.cs | 35 ++- .../Hooks/GatePointTests.cs | 2 +- .../Hooks/GateRunnerTests.cs | 12 +- .../Hooks/HookGateLazyInputTests.cs | 5 +- .../Hooks/HookGateTests.cs | 5 +- .../Hooks/HookSessions.cs | 1 - .../Hooks/HookTableTests.cs | 14 +- .../Hooks/ModelGateTests.cs | 17 +- .../Hooks/NoticeHubCostTests.cs | 5 +- .../Hooks/NoticeHubTests.cs | 11 +- .../Hooks/NoticeHubTimeoutTests.cs | 10 +- .../Hooks/NoticeProbes.cs | 24 +- .../Hooks/Recorder.cs | 35 ++- .../Hooks/RunEndGateTests.cs | 19 +- .../Hooks/RunFactNoticeTests.cs | 7 +- .../Hooks/RunGateTests.cs | 4 +- .../Hooks/SessionFaultNoticeTests.cs | 10 +- .../Hooks/SessionHooksTests.cs | 6 +- .../Hooks/TelemetryHookTests.cs | 8 +- .../Hooks/ToolCalledNoticeTests.cs | 6 +- .../Hooks/ToolGateTests.cs | 10 +- .../Hooks/TurnGateTests.cs | 9 +- .../Hooks/UsageModel.cs | 12 +- .../Hooks/WarningThrowingLogger.cs | 14 +- .../Knowledge/KnowledgeProbeTests.cs | 1 - .../KnowledgeProviderFactoryCitationTests.cs | 1 - .../KnowledgeProviderFactoryLimitTests.cs | 1 - .../KnowledgeProviderFactoryModeTests.cs | 1 - .../KnowledgeProviderFactoryScopeTests.cs | 1 - .../KnowledgeProviderFactoryTestSupport.cs | 2 +- .../AgentDelegationBackgroundReleaseTests.cs | 13 +- .../Runtime/CallForever.cs | 5 +- ...ersationTurnStreamCompactionNoticeTests.cs | 6 +- .../Runtime/ConversationBusyMarkTests.cs | 2 +- .../ConversationSessionBargeInTests.cs | 2 +- .../Runtime/ConversationSessionCutTests.cs | 3 +- .../ConversationSessionCutWindowTests.cs | 1 - .../ConversationSessionEditApprovalTests.cs | 2 +- .../Runtime/ConversationSessionEditTests.cs | 2 - .../ConversationSessionGraphCancelTests.cs | 9 +- .../ConversationSessionGraphFaultTests.cs | 2 +- .../ConversationSessionGraphIdTests.cs | 10 +- .../ConversationSessionModelFaultTests.cs | 1 - ...onversationSessionParallelApprovalTests.cs | 1 - .../ConversationSessionTurnRunTests.cs | 2 +- .../Runtime/ConversationTurnAgentCutTests.cs | 3 +- .../Runtime/ConversationTurnAgentHarness.cs | 1 - .../Runtime/ConversationTurnAgentSealTests.cs | 1 - .../Runtime/ConversationTurnAgentTests.cs | 1 - ...versationWorkspaceMarkerVisibilityTests.cs | 6 +- .../Runtime/ConversationWorkspaceTests.cs | 2 +- .../Runtime/CutTurnBackgroundToolTests.cs | 6 +- .../Runtime/FailsOnceThenAnswersModel.cs | 5 +- .../Runtime/GraphParticipantAgentTests.cs | 5 +- .../Runtime/HarnessTurn.cs | 1 - .../GatedToolThenReplyChatClient.cs | 1 - .../Runtime/RawEventAgent.cs | 16 +- .../Runtime/RunningToolInterruptTests.cs | 16 +- .../Runtime/RuntimeFakes.cs | 1 - .../Runtime/ToolCancelOutsideTurnTests.cs | 4 +- .../Runtime/TurnFaultTelemetryTests.cs | 2 +- .../ConversationSessionGetOrOpenTests.cs | 4 +- ...versationSessionWorkspaceHeartbeatTests.cs | 2 +- .../InMemoryConversationSessionsSweepTests.cs | 8 +- .../Sessions/OwnThread.cs | 6 +- .../Tools/FilePublishToolDefinitionTests.cs | 2 +- .../Tools/MeetingFunction.cs | 2 +- ...AgentCoreChatHistoryProviderCommitTests.cs | 2 +- ...AgentCoreChatHistoryProviderTestSupport.cs | 1 - .../Transcript/CapturingChatClient.cs | 1 - .../ConversationSessionCatchUpTests.cs | 2 +- .../ConversationSessionRefusedCatchUpTests.cs | 1 - .../ConversationSessionTranscriptTests.cs | 1 - .../Transcript/FrontVoiceCommitTests.cs | 8 +- .../Calls/PhoneCallAskTests.cs | 19 +- .../Calls/PhoneCallChannelTests.cs | 5 +- .../Calls/PhoneCallEndedNoticeTests.cs | 16 +- .../Calls/PhoneCallFrontVoiceTests.cs | 6 +- .../Calls/PhoneCallTakeoverTests.cs | 2 +- .../Calls/PhoneCallTests.cs | 2 +- .../DefaultSessionExpiryTests.cs | 1 - .../FillerKeyCheckTests.cs | 1 - .../Endpoints/EntryGateFactsTests.cs | 3 +- .../Endpoints/HttpSessionOwnerReuseTests.cs | 1 - .../Endpoints/ResponsesApprovalTests.cs | 2 +- .../ResponsesConversationInUseTests.cs | 1 - .../Endpoints/ResponsesMultiStepAbortTests.cs | 1 - .../ResponsesParallelApprovalTests.cs | 2 +- .../Endpoints/ResponsesTurnConflictTests.cs | 2 +- .../Fakes/CountingConversationStore.cs | 1 - .../Fakes/FakeSideband.cs | 10 +- .../Fakes/FakeWebSocket.cs | 9 - .../Fakes/GatedConversationSessions.cs | 36 ++- .../Fakes/LatencyRecorder.cs | 2 +- .../Fakes/OpenAiLiveHost.cs | 14 +- .../Fakes/PhoneCallHarness.cs | 1 - .../Fakes/RecordingAuditSink.cs | 1 - .../Fakes/RelayConnectionHarness.cs | 1 - .../Fakes/ScriptedVoiceTurn.cs | 20 +- .../Fakes/TelnyxRelayHost.cs | 4 +- .../Hooks/HookRegistrationTests.cs | 6 +- .../OpenAiLive/LiveEventReaderTests.cs | 2 +- .../Vendors/OpenAiLive/LiveSent.cs | 21 +- .../OpenAiLive/LiveTranscriptLedgerTests.cs | 4 +- .../Vendors/OpenAiLive/OpenAiLiveCallTests.cs | 2 +- .../OpenAiLive/OpenAiLiveCallsTests.cs | 4 +- .../OpenAiLive/OpenAiLiveClockTests.cs | 5 +- .../OpenAiLive/OpenAiLiveFrontVoiceTests.cs | 12 +- .../OpenAiLive/OpenAiLiveTransferTests.cs | 25 ++- .../OpenAiLive/OpenAiLiveVoiceContextTests.cs | 10 +- .../OpenAiLiveWebhookCleanupTests.cs | 11 +- .../OpenAiLive/OpenAiLiveWebhookTests.cs | 6 +- .../StandardWebhookSignatureTests.cs | 2 +- .../OpenAiLive/WebSocketSidebandTests.cs | 2 +- .../TelnyxRelay/TelnyxRelayCallGateTests.cs | 3 +- .../TelnyxRelayMultiStepCutTests.cs | 1 - .../Voice/AsyncEventTests.cs | 2 +- .../Voice/ConversationRouteSelectionTests.cs | 20 +- .../Voice/FillerSchedulerTests.cs | 8 +- .../Voice/SpeechHandleTests.cs | 5 +- .../Voice/SpeechSchedulerTests.cs | 5 +- .../Voice/UserTurnHandlerTests.cs | 4 +- .../Voice/VoiceActivityHooksTests.cs | 4 - .../AgentCoreHostRouteTests.cs | 2 - ...tgresConversationStoreTurnConflictTests.cs | 2 +- .../PostgresConversationVerificationTests.cs | 2 - .../Postgres/PostgresTurnVerificationTests.cs | 1 - .../Qdrant/AmbiguityIntegrationTests.cs | 1 - .../OpenCodeGoChatClientAdapterTests.cs | 2 +- .../OpenCodeGoReasoningEffortTests.cs | 3 +- tests/AgentCore.TestSupport/GatedTool.cs | 5 +- .../MapModelCatalogPort.cs | 2 +- tests/AgentCore.TestSupport/RecordingHook.cs | 178 ++++++++++++--- 223 files changed, 1338 insertions(+), 651 deletions(-) diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs index 3de0e9cf..12003f32 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs @@ -101,7 +101,7 @@ private static void Add( if (context.ChatClients.ResolveHostedTool(tool, model) is not { } resolved) { ILogger logger = context.Loggers?.CreateLogger(typeof(AgentToolCompiler)) ?? NullLogger.Instance; - + string modelDescription = model is { Ref.Length: > 0 } ? $"the model '{model.Ref}'" : "this agent's default model"; Log.HostedToolDropped(logger, agentId, toolId, tool.GetType().Name, modelDescription); diff --git a/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs b/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs index 68cdfb2e..012fa3e8 100644 --- a/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs +++ b/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs @@ -38,7 +38,7 @@ internal override EntryBuild BuildEntry( StageConfiguration stage = policy.Stages[index]; string stagePointer = ConfigurationError.AppendPointer( ConfigurationError.AppendPointer(policyPointer, "stages"), index); - + if (stage.Agent is not { } agentId) { stages[stage.Id] = NoAgentId; diff --git a/src/AgentCore.Application/Hooks/AgentHook.cs b/src/AgentCore.Application/Hooks/AgentHook.cs index d55e754e..0462e1cd 100644 --- a/src/AgentCore.Application/Hooks/AgentHook.cs +++ b/src/AgentCore.Application/Hooks/AgentHook.cs @@ -12,253 +12,379 @@ public abstract class AgentHook /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask BeforeCallAsync(CallGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask BeforeCallAsync(CallGate gate, CancellationToken cancellationToken) + { + return default; + } /// Every AgentCore route, HTTP or call, needs an entry. Decide with or . /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken cancellationToken) + { + return default; + } /// The user's message arrives, before moderation and the model. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) + { + return default; + } /// Once per run, when the context is built. A graph participant or an agent-as-tool child is a run of its own, so it fires again with set. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken) + { + return default; + } /// AgentCore is about to compact. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask BeforeCompactionAsync(CompactionGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask BeforeCompactionAsync(CompactionGate gate, CancellationToken cancellationToken) + { + return default; + } /// Before each model round trip. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) + { + return default; + } /// After each round trip, before tools run or text leaves. Overriding this method buffers every streamed model round of every turn before any of it reaches the caller. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) + { + return default; + } /// A round trip throws. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask AfterModelFailedAsync(ModelFailureGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask AfterModelFailedAsync(ModelFailureGate gate, CancellationToken cancellationToken) + { + return default; + } /// A tool that needs approval is called. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask BeforeToolApprovalAsync(ApprovalGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask BeforeToolApprovalAsync(ApprovalGate gate, CancellationToken cancellationToken) + { + return default; + } /// Before each tool call. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) + { + return default; + } /// After a tool returns. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) + { + return default; + } /// A tool throws. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask AfterToolFailedAsync(ToolFailureGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask AfterToolFailedAsync(ToolFailureGate gate, CancellationToken cancellationToken) + { + return default; + } /// The run has a reply, before the turn is sealed. /// The facts and the verbs of this gate. /// Cancelled at the gate's deadline. /// A task that completes when the hook decided. - public virtual ValueTask AfterRunAsync(RunEndGate gate, CancellationToken cancellationToken) => default; + public virtual ValueTask AfterRunAsync(RunEndGate gate, CancellationToken cancellationToken) + { + return default; + } /// Boot finished. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) + { + return default; + } /// Shutdown begins. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnHostStoppingAsync(HostStopping notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnHostStoppingAsync(HostStopping notice, CancellationToken cancellationToken) + { + return default; + } /// The hourly retention sweep ran. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnRetentionSweptAsync(RetentionSwept notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnRetentionSweptAsync(RetentionSwept notice, CancellationToken cancellationToken) + { + return default; + } /// A call is accepted and the session is open. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnCallStartedAsync(CallStarted notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnCallStartedAsync(CallStarted notice, CancellationToken cancellationToken) + { + return default; + } /// A spoken line closes. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnLineSpokenAsync(LineSpoken notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnLineSpokenAsync(LineSpoken notice, CancellationToken cancellationToken) + { + return default; + } /// After the store opens. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) + { + return default; + } /// Idle unload or close; the conversation lives on in the store. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) + { + return default; + } /// The conversation ends. Once per conversation. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) + { + return default; + } /// The turn is admitted and its agent chosen. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) + { + return default; + } /// Any refusal. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) + { + return default; + } /// Edit-and-resend withdrew turns. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) + { + return default; + } /// Moderation decided. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) + { + return default; + } /// A knowledge search finished. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnKnowledgeSearchedAsync(KnowledgeSearched notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnKnowledgeSearchedAsync(KnowledgeSearched notice, CancellationToken cancellationToken) + { + return default; + } /// A skill was pinned or loaded. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnSkillLoadedAsync(SkillLoaded notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnSkillLoadedAsync(SkillLoaded notice, CancellationToken cancellationToken) + { + return default; + } /// Compaction finished or failed. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnCompactedAsync(Compacted notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnCompactedAsync(Compacted notice, CancellationToken cancellationToken) + { + return default; + } /// Each output update that leaves the turn. Overriding this method makes AgentCore raise it for every update. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnReplyUpdatedAsync(ReplyUpdated notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnReplyUpdatedAsync(ReplyUpdated notice, CancellationToken cancellationToken) + { + return default; + } /// Each model round trip ended. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnModelCalledAsync(ModelCalled notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnModelCalledAsync(ModelCalled notice, CancellationToken cancellationToken) + { + return default; + } /// Each tool call ended. One per call. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) + { + return default; + } /// Approval asked or answered. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnApprovalChangedAsync(ApprovalChanged notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnApprovalChangedAsync(ApprovalChanged notice, CancellationToken cancellationToken) + { + return default; + } /// file.publish stored a file. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnFilePublishedAsync(FilePublished notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnFilePublishedAsync(FilePublished notice, CancellationToken cancellationToken) + { + return default; + } /// An agent-as-tool run began. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnSubagentStartedAsync(SubagentStarted notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnSubagentStartedAsync(SubagentStarted notice, CancellationToken cancellationToken) + { + return default; + } /// An agent-as-tool run ended. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnSubagentEndedAsync(SubagentEnded notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnSubagentEndedAsync(SubagentEnded notice, CancellationToken cancellationToken) + { + return default; + } /// The turn is sealed. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) + { + return default; + } /// The caller spoke over the reply (cut or recut). /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) + { + return default; + } /// The user or agent voice state changed. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnVoiceStateChangedAsync(VoiceStateChanged notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnVoiceStateChangedAsync(VoiceStateChanged notice, CancellationToken cancellationToken) + { + return default; + } /// Voice turn timing is known. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnTurnLatencyAsync(TurnLatency notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnTurnLatencyAsync(TurnLatency notice, CancellationToken cancellationToken) + { + return default; + } /// The stage machine moved. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnStageChangedAsync(StageChanged notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnStageChangedAsync(StageChanged notice, CancellationToken cancellationToken) + { + return default; + } /// A non-fatal fault. /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) + { + return default; + } /// A started call left. Once per call, also after . /// The fact. /// Cancelled when passes. /// A task that completes when the hook is done with the notice. - public virtual ValueTask OnCallEndedAsync(CallEnded notice, CancellationToken cancellationToken) => default; + public virtual ValueTask OnCallEndedAsync(CallEnded notice, CancellationToken cancellationToken) + { + return default; + } /// Gets how a gate of this hook fails. Override per point to fail closed where the gate fails open. /// The gate point. diff --git a/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs index 0e9fd22a..6dfbfa23 100644 --- a/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs +++ b/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs @@ -22,8 +22,10 @@ internal sealed class AuditHook(IAuditSinkPort sink, ILogger logger) : AgentHook public override TimeSpan? NoticeTimeout => null; /// - public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) => - AppendAsync(notice, AuditEventKind.ConversationStarted, NoPayload, amends: null, cancellationToken); + public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) + { + return AppendAsync(notice, AuditEventKind.ConversationStarted, NoPayload, amends: null, cancellationToken); + } /// public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) @@ -38,32 +40,39 @@ public override ValueTask OnConversationEndedAsync(ConversationEnded notice, Can } /// - public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => - !AuditRules.IsAudited(notice) + public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) + { + return !AuditRules.IsAudited(notice) ? ValueTask.CompletedTask : AppendAsync(notice, AuditEventKind.TurnRefused, new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.RefusedReason] = TurnRefusalTokens.ToToken(notice.Reason) }, amends: null, cancellationToken); + } /// - public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) => - AppendAsync(notice, AuditEventKind.TurnSuperseded, new Dictionary(StringComparer.Ordinal) + public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) + { + return AppendAsync(notice, AuditEventKind.TurnSuperseded, new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.WithdrewFromTurnIndex] = notice.WithdrewFrom.ToString(CultureInfo.InvariantCulture), [AuditPayloadKeys.WithdrewThroughTurnIndex] = notice.WithdrewThrough.ToString(CultureInfo.InvariantCulture), }, amends: null, cancellationToken); + } /// - public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) => - !AuditRules.IsAudited(notice) + public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) + { + return !AuditRules.IsAudited(notice) ? ValueTask.CompletedTask : AppendAsync(notice, AuditEventKind.PromptFlagged, new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.ModerationCategories] = string.Join(',', notice.Categories) }, amends: null, cancellationToken); + } /// - public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) => - !AuditRules.IsAudited(notice) + public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) + { + return !AuditRules.IsAudited(notice) ? ValueTask.CompletedTask : AppendAsync(notice, AuditEventKind.ToolFailed, new Dictionary(StringComparer.Ordinal) { @@ -72,15 +81,18 @@ public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken [AuditPayloadKeys.ToolFailureKind] = ToolFailureKinds.ToToken(notice.FailureKind ?? ToolFailureKind.Faulted), [AuditPayloadKeys.ToolError] = notice.Failure ?? string.Empty, }, amends: null, cancellationToken); + } /// - public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => - AppendAsync(notice, AuditEventKind.TurnCompleted, new Dictionary(StringComparer.Ordinal) + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) + { + return AppendAsync(notice, AuditEventKind.TurnCompleted, new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(notice.ReplyText).Value, [AuditPayloadKeys.StageBefore] = notice.StageBefore, [AuditPayloadKeys.StageAfter] = notice.StageAfter, }, amends: null, cancellationToken); + } /// public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) diff --git a/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs b/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs index 35efe788..17e8540a 100644 --- a/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs +++ b/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs @@ -9,13 +9,21 @@ namespace AgentCore.Application.Hooks.BuiltIn internal static class AuditRules { /// A fault the model cannot answer, or a tool the model named that no one declared, is a tool.failed row. - internal static bool IsAudited(ToolCalled notice) => - notice is { Outcome: ToolOutcome.Failed, Fatal: true } or { Outcome: ToolOutcome.Undeclared }; + internal static bool IsAudited(ToolCalled notice) + { + return notice is { Outcome: ToolOutcome.Failed, Fatal: true } or { Outcome: ToolOutcome.Undeclared }; + } /// A refusal after conversation.ended is no row: nothing is appended behind the end. - internal static bool IsAudited(TurnRefused notice) => !notice.AfterEnd; + internal static bool IsAudited(TurnRefused notice) + { + return !notice.AfterEnd; + } /// Only flagged words are a prompt.flagged row. - internal static bool IsAudited(InputModerated notice) => notice.Verdict == InputVerdict.Flagged; + internal static bool IsAudited(InputModerated notice) + { + return notice.Verdict == InputVerdict.Flagged; + } } } diff --git a/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs index bbdb6ba9..292526de 100644 --- a/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs +++ b/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs @@ -78,8 +78,14 @@ public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellat return ValueTask.CompletedTask; } - private static string IdOf(HookNotice notice) => notice.Scope.ConversationId ?? string.Empty; + private static string IdOf(HookNotice notice) + { + return notice.Scope.ConversationId ?? string.Empty; + } - private static int TurnOf(HookNotice notice) => notice.Scope.TurnIndex ?? NoTurn; + private static int TurnOf(HookNotice notice) + { + return notice.Scope.TurnIndex ?? NoTurn; + } } } diff --git a/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs index 8ef20414..7dc5c2e0 100644 --- a/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs +++ b/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs @@ -11,24 +11,34 @@ namespace AgentCore.Application.Hooks.BuiltIn internal sealed class TelemetryHook : AgentHook { /// - public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) => - Audited(AuditEventKind.ConversationStarted); + public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) + { + return Audited(AuditEventKind.ConversationStarted); + } /// - public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => - Audited(AuditEventKind.ConversationEnded); + public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) + { + return Audited(AuditEventKind.ConversationEnded); + } /// - public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => - AuditRules.IsAudited(notice) ? Audited(AuditEventKind.TurnRefused) : ValueTask.CompletedTask; + public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) + { + return AuditRules.IsAudited(notice) ? Audited(AuditEventKind.TurnRefused) : ValueTask.CompletedTask; + } /// - public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) => - Audited(AuditEventKind.TurnSuperseded); + public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) + { + return Audited(AuditEventKind.TurnSuperseded); + } /// - public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) => - Audited(AuditEventKind.ReplyInterrupted); + public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) + { + return Audited(AuditEventKind.ReplyInterrupted); + } /// public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) @@ -45,8 +55,10 @@ public override ValueTask OnInputModeratedAsync(InputModerated notice, Cancellat } /// - public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) => - AuditRules.IsAudited(notice) ? Audited(AuditEventKind.ToolFailed) : ValueTask.CompletedTask; + public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) + { + return AuditRules.IsAudited(notice) ? Audited(AuditEventKind.ToolFailed) : ValueTask.CompletedTask; + } /// public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) diff --git a/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs b/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs index 538c5001..b4f92619 100644 --- a/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs +++ b/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs @@ -31,13 +31,16 @@ internal enum Row /// How the turn ended. /// Whether the fallback came from a tool that spent its retry budget. /// The row, or . - internal static Row Of(TurnOutcome outcome, bool failedInTool) => (outcome, failedInTool) switch + internal static Row Of(TurnOutcome outcome, bool failedInTool) { - (TurnOutcome.Empty, _) => Row.EmptyReply, - (TurnOutcome.Fallback, true) => Row.ToolBudget, - (TurnOutcome.Fallback or TurnOutcome.Faulted, _) => Row.RunFault, - _ => Row.None, - }; + return (outcome, failedInTool) switch + { + (TurnOutcome.Empty, _) => Row.EmptyReply, + (TurnOutcome.Fallback, true) => Row.ToolBudget, + (TurnOutcome.Fallback or TurnOutcome.Faulted, _) => Row.RunFault, + _ => Row.None, + }; + } /// Counts the row on agentcore.turn.failures. /// The row. diff --git a/src/AgentCore.Application/Hooks/Engine/GateRunner.cs b/src/AgentCore.Application/Hooks/Engine/GateRunner.cs index 92d0cc13..518f629a 100644 --- a/src/AgentCore.Application/Hooks/Engine/GateRunner.cs +++ b/src/AgentCore.Application/Hooks/Engine/GateRunner.cs @@ -11,7 +11,10 @@ internal sealed class GateRunner(HookTable table, ILogger logger, TimeProvider t { internal HookTable Table => table; - internal bool Overrides(GatePoint point) => table.Overrides(point); + internal bool Overrides(GatePoint point) + { + return table.Overrides(point); + } /// Runs the chain and returns the value the hooks left. /// The gate's view type. @@ -89,8 +92,10 @@ private static HookFailure FailureOf(AgentHook hook, GatePoint point) } // A hook's own TimeoutException faulted its task; the wait's own timeout is a fresh exception. - private static bool ThrownBy(Task running, Exception exception) => - running.Exception?.InnerExceptions.Contains(exception) == true; + private static bool ThrownBy(Task running, Exception exception) + { + return running.Exception?.InnerExceptions.Contains(exception) == true; + } // Disposing a source unhooks it from the turn token and stops its timer, so a hook still running would // never see its token cancelled. The sources live until the hook ends, however long that is. diff --git a/src/AgentCore.Application/Hooks/Engine/HookReader.cs b/src/AgentCore.Application/Hooks/Engine/HookReader.cs index a03a7d1a..958472f9 100644 --- a/src/AgentCore.Application/Hooks/Engine/HookReader.cs +++ b/src/AgentCore.Application/Hooks/Engine/HookReader.cs @@ -43,7 +43,10 @@ internal HookReader(Mailbox box, AgentHook hook, IReadOnlySet wants) internal bool IsIdle => Volatile.Read(ref _pending) == 0; - internal bool Wants(HookNotice notice) => _wants.Contains(notice.GetType()); + internal bool Wants(HookNotice notice) + { + return _wants.Contains(notice.GetType()); + } /// Queues one notice. Runs under the mailbox lock, so queue order is Sequence order. internal void Write(HookNotice notice) @@ -108,7 +111,10 @@ internal void Complete() }; } - private static string NameOf(AgentHook hook) => hook.GetType().FullName ?? hook.GetType().Name; + private static string NameOf(AgentHook hook) + { + return hook.GetType().FullName ?? hook.GetType().Name; + } // The reader lives as long as the mailbox. Started with the raiser's context, every later delivery would // run inside the first turn's trace, logging scopes and run context, and keep them alive. diff --git a/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs b/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs index 880483c1..d749bd55 100644 --- a/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs +++ b/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs @@ -54,7 +54,11 @@ internal HookRuntime WithNoticeHooks(IEnumerable hooks) AgentHook[] added = [.. hooks]; foreach (AgentHook hook in added) { - ArgumentNullException.ThrowIfNull(hook, nameof(hooks)); + if (hook is null) + { + throw new ArgumentNullException(nameof(hooks), "A hook in the list is null."); + } + if (!Table.Hooks.Contains(hook, ReferenceEqualityComparer.Instance) && HookTable.Build([hook]).OverridesAnyGate(hook)) { throw new ArgumentException( @@ -74,9 +78,15 @@ internal void RaiseHostFault(Fault fault, AgentHook except) _ = Notices.Raise(fault, Timers, except: except); } - internal ValueTask StopAsync(TimeSpan timeout) => Notices.StopAsync(timeout); + internal ValueTask StopAsync(TimeSpan timeout) + { + return Notices.StopAsync(timeout); + } /// - public ValueTask DisposeAsync() => StopAsync(StopTimeout); + public ValueTask DisposeAsync() + { + return StopAsync(StopTimeout); + } } } diff --git a/src/AgentCore.Application/Hooks/Engine/HookScopes.cs b/src/AgentCore.Application/Hooks/Engine/HookScopes.cs index 78649038..c36aab67 100644 --- a/src/AgentCore.Application/Hooks/Engine/HookScopes.cs +++ b/src/AgentCore.Application/Hooks/Engine/HookScopes.cs @@ -3,9 +3,14 @@ namespace AgentCore.Application.Hooks.Engine /// Scopes for the gates and notices that have no session. internal static class HookScopes { - internal static HookScope Host(DateTimeOffset now) => new(null, null, null, null, Guid.Empty, 0, now); + internal static HookScope Host(DateTimeOffset now) + { + return new(null, null, null, null, Guid.Empty, 0, now); + } - internal static HookScope ForGate(string? conversationId, string? entry, DateTimeOffset now) => - new(conversationId, entry, null, null, Guid.Empty, 0, now); + internal static HookScope ForGate(string? conversationId, string? entry, DateTimeOffset now) + { + return new(conversationId, entry, null, null, Guid.Empty, 0, now); + } } } diff --git a/src/AgentCore.Application/Hooks/Engine/HookTable.cs b/src/AgentCore.Application/Hooks/Engine/HookTable.cs index acce6e8a..ef0791a0 100644 --- a/src/AgentCore.Application/Hooks/Engine/HookTable.cs +++ b/src/AgentCore.Application/Hooks/Engine/HookTable.cs @@ -66,7 +66,11 @@ internal static HookTable Build(IReadOnlyList hooks) HashSet seen = new(ReferenceEqualityComparer.Instance); foreach (AgentHook hook in hooks) { - ArgumentNullException.ThrowIfNull(hook, nameof(hooks)); + if (hook is null) + { + throw new ArgumentNullException(nameof(hooks), "A hook in the list is null."); + } + if (seen.Add(hook)) { distinct.Add(hook); @@ -76,17 +80,30 @@ internal static HookTable Build(IReadOnlyList hooks) return new HookTable(distinct); } - internal AgentHook[] For(GatePoint point) => _gates[point]; + internal AgentHook[] For(GatePoint point) + { + return _gates[point]; + } - internal bool Overrides(GatePoint point) => _gates[point].Length > 0; + internal bool Overrides(GatePoint point) + { + return _gates[point].Length > 0; + } - internal IReadOnlySet NoticesOf(AgentHook hook) => - _notices.TryGetValue(hook, out IReadOnlySet? types) ? types : NoNotices; + internal IReadOnlySet NoticesOf(AgentHook hook) + { + return _notices.TryGetValue(hook, out IReadOnlySet? types) ? types : NoNotices; + } - internal bool Wants(Type noticeType) => _wanted.Contains(noticeType); + internal bool Wants(Type noticeType) + { + return _wanted.Contains(noticeType); + } - internal bool OverridesAnyGate(AgentHook hook) => - GatePoint.All.Any(point => Array.Exists(_gates[point], listed => ReferenceEquals(listed, hook))); + internal bool OverridesAnyGate(AgentHook hook) + { + return GatePoint.All.Any(point => Array.Exists(_gates[point], listed => ReferenceEquals(listed, hook))); + } /// Whether a hook type overrides one gate, read from the type alone. /// The hook's type. @@ -97,7 +114,10 @@ internal static bool TypeOverrides(Type hookType, GatePoint point) return TypeOverrides(hookType, typeof(AgentHook).GetMethod(point.MethodName)!); } - private static bool Overrides(AgentHook hook, MethodInfo baseMethod) => TypeOverrides(hook.GetType(), baseMethod); + private static bool Overrides(AgentHook hook, MethodInfo baseMethod) + { + return TypeOverrides(hook.GetType(), baseMethod); + } // GetBaseDefinition tells an override apart from a method hidden with `new`, which the base dispatch never calls. private static bool TypeOverrides(Type hookType, MethodInfo baseMethod) diff --git a/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs b/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs index 95c935b8..b6121bd2 100644 --- a/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs +++ b/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs @@ -28,7 +28,10 @@ internal sealed class NoticeHub(HookTable table, ILogger logger, TimeProvider ti internal int MailboxCount => _mailboxes.Count; - internal bool Wants(Type noticeType) => table.Wants(noticeType); + internal bool Wants(Type noticeType) + { + return table.Wants(noticeType); + } /// Stamps one notice and queues it for every hook that wants it. /// The fact, with its scope's conversation, session and turn already set. diff --git a/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs b/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs index 0bb742fb..d752451e 100644 --- a/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs +++ b/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs @@ -49,16 +49,24 @@ internal SessionHooks(HookRuntime runtime, string conversationId, string entry, /// internal ConcurrentDictionary ApprovalDecisions { get; } = new(StringComparer.Ordinal); - internal HookScope Scope(int? turnIndex, string? stage) => - new(_conversationId, _entry, turnIndex, stage, SessionId, 0, _time.GetUtcNow()); + internal HookScope Scope(int? turnIndex, string? stage) + { + return new(_conversationId, _entry, turnIndex, stage, SessionId, 0, _time.GetUtcNow()); + } internal bool Wants() - where TNotice : HookNotice => _runtime.Notices.Wants(typeof(TNotice)); + where TNotice : HookNotice + { + return _runtime.Notices.Wants(typeof(TNotice)); + } /// Raises one notice of this session. /// The fact. /// When it happened, if it was read before the raise (a turn's end), or for now. - internal Guid Raise(HookNotice notice, DateTimeOffset? occurredAt = null) => _runtime.Notices.Raise(notice, _time, occurredAt: occurredAt); + internal Guid Raise(HookNotice notice, DateTimeOffset? occurredAt = null) + { + return _runtime.Notices.Raise(notice, _time, occurredAt: occurredAt); + } /// /// Raises , once per session. The caller holds the session's turn lock, so the @@ -112,7 +120,10 @@ internal ConversationOrigin OriginOf(bool storeHeldConversation) return storeHeldConversation ? ConversationOrigin.Resumed : ConversationOrigin.New; } - internal Task FlushAsync() => _runtime.Notices.FlushAsync(_conversationId); + internal Task FlushAsync() + { + return _runtime.Notices.FlushAsync(_conversationId); + } /// Lets go of the mailbox pin, once. The session was disposed or unloaded. internal void Release() diff --git a/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs b/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs index 4174dc4c..01dd79f1 100644 --- a/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs +++ b/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs @@ -5,7 +5,10 @@ internal sealed class TurnRounds { private int _next = -1; - internal int Next() => Interlocked.Increment(ref _next); + internal int Next() + { + return Interlocked.Increment(ref _next); + } /// Gets how many model round trips the turn has made so far. internal int Count => Volatile.Read(ref _next) + 1; diff --git a/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs b/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs index 74fb28b0..8b789da8 100644 --- a/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs +++ b/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs @@ -157,10 +157,16 @@ private static ChatMessage Answer(List<(ToolApprovalRequestContent Request, stri return new(ChatRole.User, [.. denied.Select(static pair => (AIContent)pair.Request.CreateResponse(false, pair.Reason))]); } - private static int? RoundsSoFar(AgentRunOptions? options) => TurnInvocation.From(options)?.Rounds?.Count; + private static int? RoundsSoFar(AgentRunOptions? options) + { + return TurnInvocation.From(options)?.Rounds?.Count; + } // Without a round counter every run counts, which can only end the run sooner. - private static bool CalledTheModel(int? before, AgentRunOptions? options) => before is null || RoundsSoFar(options) != before; + private static bool CalledTheModel(int? before, AgentRunOptions? options) + { + return before is null || RoundsSoFar(options) != before; + } /// The cap is reached with a denial still open: never a person's to answer. Logs, raises the fault, and throws. private static InvalidOperationException Exhausted(List<(ToolApprovalRequestContent Request, string Reason)> denied, AgentRunOptions? options) diff --git a/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs b/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs index b25f2627..603fd63c 100644 --- a/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs +++ b/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs @@ -198,7 +198,6 @@ public override async IAsyncEnumerable GetStreamingResponseA { foreach (ChatResponseUpdate update in seen) { - yielded = true; yield return update; } } @@ -230,6 +229,9 @@ public override async IAsyncEnumerable GetStreamingResponseA } } - private static TurnInvocation? Turn() => TurnInvocation.From(AIAgent.CurrentRunContext?.RunOptions); + private static TurnInvocation? Turn() + { + return TurnInvocation.From(AIAgent.CurrentRunContext?.RunOptions); + } } } diff --git a/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs b/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs index 77adef67..07a7e382 100644 --- a/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs +++ b/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs @@ -59,7 +59,10 @@ internal static ValueTask FailedAsync( cancellationToken); } - private static ChatResponse Refusal(ModelRound round) => new(new ChatMessage(ChatRole.Assistant, round.RefusalReply)); + private static ChatResponse Refusal(ModelRound round) + { + return new(new ChatMessage(ChatRole.Assistant, round.RefusalReply)); + } /// What the model is asked after BeforeModel, or the answer a hook gave in its place. internal sealed record Asked(IReadOnlyList Messages, ChatOptions? Options, ChatResponse? Answer); diff --git a/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs b/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs index dc72fd63..67653dd6 100644 --- a/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs +++ b/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs @@ -21,12 +21,16 @@ internal static void Mark(AIFunctionArguments arguments, object key) arguments.Context[key] = true; } - internal static bool Marked(AIFunctionArguments arguments, object key) => - arguments.Context?.ContainsKey(key) == true; + internal static bool Marked(AIFunctionArguments arguments, object key) + { + return arguments.Context?.ContainsKey(key) == true; + } - internal static ToolOutcome Of(AIFunctionArguments arguments) => - Marked(arguments, TimedOutKey) ? ToolOutcome.TimedOut + internal static ToolOutcome Of(AIFunctionArguments arguments) + { + return Marked(arguments, TimedOutKey) ? ToolOutcome.TimedOut : Marked(arguments, CachedKey) ? ToolOutcome.Cached : ToolOutcome.Ok; + } } } diff --git a/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs b/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs index 9ec4b75e..ef2cc060 100644 --- a/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs +++ b/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs @@ -42,8 +42,10 @@ internal static AIAgent Apply(AIAgent agent, HookRuntime hooks) .Build(); } - private static AgentRunOptions? Fresh(AgentRunOptions? options) => - options is ChatClientAgentRunOptions run ? run.Clone() : options; + private static AgentRunOptions? Fresh(AgentRunOptions? options) + { + return options is ChatClientAgentRunOptions run ? run.Clone() : options; + } private sealed record Before(Dictionary Arguments, bool Blocked, bool Responded, object? Result, bool EndLoop); @@ -227,9 +229,11 @@ private static void RaiseSkillLoaded(Call call, AIFunctionArguments arguments, o } // MAF's load_skill answers an unknown name with this text (SkillsProviderFactoryTests pins it), serialized. - private static bool SkillNotFound(string skill, object? result) => - result is JsonElement { ValueKind: JsonValueKind.String } text + private static bool SkillNotFound(string skill, object? result) + { + return result is JsonElement { ValueKind: JsonValueKind.String } text && string.Equals(text.GetString(), $"Error: Skill '{skill}' not found.", StringComparison.Ordinal); + } // A new object, because the old one wraps the model's own call record, which is stored and sent back. // It shares the old Context: that is where the turn is filed and where the wrappers leave their marks. diff --git a/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs b/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs index 5769f065..23937d65 100644 --- a/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs +++ b/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs @@ -91,7 +91,9 @@ protected override async IAsyncEnumerable RunCoreStreamingA } } - private static TurnInvocation? Filed(AgentRunOptions? options) => - TurnInvocation.From(options) is { Nested: false, User: not null, Hooks: not null } turn ? turn : null; + private static TurnInvocation? Filed(AgentRunOptions? options) + { + return TurnInvocation.From(options) is { Nested: false, User: not null, Hooks: not null } turn ? turn : null; + } } } diff --git a/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs b/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs index 2305c055..54711abf 100644 --- a/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs +++ b/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs @@ -89,7 +89,7 @@ public static async ValueTask CreateAsync( ModelCatalogEntry? found = await catalog .LookupAsync(entry.Kind, entry.Model, cancellationToken) .ConfigureAwait(false); - + factory._contextWindows[entry.As] = found?.ContextWindow; } diff --git a/src/AgentCore.Application/Runtime/Compaction/NoticingChatClient.cs b/src/AgentCore.Application/Runtime/Compaction/NoticingChatClient.cs index 6894673b..b6c61f17 100644 --- a/src/AgentCore.Application/Runtime/Compaction/NoticingChatClient.cs +++ b/src/AgentCore.Application/Runtime/Compaction/NoticingChatClient.cs @@ -8,15 +8,9 @@ namespace AgentCore.Application.Runtime.Compaction /// Wraps the summariser for one invocation and posts the start notice at the one point that /// actually means "the summariser is being called": the first request this client forwards. /// - internal sealed class NoticingChatClient : DelegatingChatClient + internal sealed class NoticingChatClient(IChatClient inner, TurnNotices? notices) : DelegatingChatClient(inner) { - private readonly TurnNotices? _notices; - - public NoticingChatClient(IChatClient inner, TurnNotices? notices) - : base(inner) - { - _notices = notices; - } + private readonly TurnNotices? _notices = notices; /// Gets whether this client has forwarded a request yet. public bool Called { get; private set; } diff --git a/src/AgentCore.Application/Runtime/Compaction/SummaryRowProvider.cs b/src/AgentCore.Application/Runtime/Compaction/SummaryRowProvider.cs index 18807d33..bbbab9e4 100644 --- a/src/AgentCore.Application/Runtime/Compaction/SummaryRowProvider.cs +++ b/src/AgentCore.Application/Runtime/Compaction/SummaryRowProvider.cs @@ -22,6 +22,8 @@ namespace AgentCore.Application.Runtime.Compaction #pragma warning disable MAAI001 // Compaction is evaluation-only in Microsoft.Agents.AI 1.21.0. internal sealed class SummaryRowProvider : AIContextProvider { + private const string Unchanged = "unchanged"; + private readonly AgentCoreChatHistoryProvider _history; private readonly IChatClient _summariser; @@ -81,14 +83,14 @@ protected override async ValueTask InvokingCoreAsync( if (gated.Cancelled) { RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Cancelled); - PostEnd(turn, client, "unchanged"); + PostEnd(turn, client, Unchanged); return input; } if (output.Count == view.Count && output.Zip(view).All(pair => ReferenceEquals(pair.First, pair.Second))) { RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Unchanged); - PostEnd(turn, client, "unchanged"); + PostEnd(turn, client, Unchanged); return input; } @@ -100,7 +102,7 @@ protected override async ValueTask InvokingCoreAsync( } RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Unchanged); - PostEnd(turn, client, "unchanged"); + PostEnd(turn, client, Unchanged); return input; } @@ -108,7 +110,7 @@ protected override async ValueTask InvokingCoreAsync( if (coversUpTo <= floor.CoversUpTo || !_history.Compact(session, output[0], coversUpTo, floor.Revision)) { RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Unchanged); - PostEnd(turn, client, "unchanged"); + PostEnd(turn, client, Unchanged); return input; } @@ -128,7 +130,7 @@ protected override async ValueTask InvokingCoreAsync( if (gated.Cancelled) { RaiseCompacted(turn, gated, messages.Count, messages.Count, CompactionOutcome.Cancelled); - PostEnd(turn, client, "unchanged"); + PostEnd(turn, client, Unchanged); return input; } diff --git a/src/AgentCore.Application/Runtime/Cut/ConversationCutTracker.cs b/src/AgentCore.Application/Runtime/Cut/ConversationCutTracker.cs index 1c7813a2..7d31b011 100644 --- a/src/AgentCore.Application/Runtime/Cut/ConversationCutTracker.cs +++ b/src/AgentCore.Application/Runtime/Cut/ConversationCutTracker.cs @@ -36,9 +36,9 @@ internal ConversationCutTracker(ConversationSession session) internal bool Cut(int turnIndex, TurnCut cut) { ArgumentNullException.ThrowIfNull(cut); - if (cut.Played is { } played) + if (cut.Played < TimeSpan.Zero) { - ArgumentOutOfRangeException.ThrowIfLessThan(played, TimeSpan.Zero, nameof(cut)); + throw new ArgumentOutOfRangeException(nameof(cut), cut.Played, "The played time is negative."); } lock (_session.TurnLock) @@ -68,9 +68,9 @@ internal bool Cut(int turnIndex, TurnCut cut) internal bool Recut(int turnIndex, TurnCut cut) { ArgumentNullException.ThrowIfNull(cut); - if (cut.Played is { } played) + if (cut.Played < TimeSpan.Zero) { - ArgumentOutOfRangeException.ThrowIfLessThan(played, TimeSpan.Zero, nameof(cut)); + throw new ArgumentOutOfRangeException(nameof(cut), cut.Played, "The played time is negative."); } lock (_session.TurnLock) diff --git a/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs b/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs index 76e48fed..326fcd55 100644 --- a/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs +++ b/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs @@ -53,7 +53,7 @@ public static async ValueTask ReleaseAsync( try { long started = Stopwatch.GetTimestamp(); - + await provider.ReleaseSessionAsync(session, cancelRunning: true, ReleaseTimeout, cancellationToken) .ConfigureAwait(false); diff --git a/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs b/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs index ff69309a..15eb9866 100644 --- a/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs +++ b/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs @@ -21,7 +21,7 @@ internal void Attach(IConversationChannel channel) public ChannelCommandResult Send(ChannelCommand command) { ArgumentNullException.ThrowIfNull(command); - + if (session.IsComplete || session.Lifetime.Ending.Requested) { return ChannelCommandResult.Ending; diff --git a/src/AgentCore.Application/Runtime/Session/ConversationBusyMark.cs b/src/AgentCore.Application/Runtime/Session/ConversationBusyMark.cs index 14f2a963..03318827 100644 --- a/src/AgentCore.Application/Runtime/Session/ConversationBusyMark.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationBusyMark.cs @@ -109,7 +109,7 @@ private async ValueTask HoldAsync(long started, CancellationToken cancellationTo internal async ValueTask EnterRequestAsync(long started, CancellationToken cancellationToken) { TimeSpan left = WaitLimit - _session.Time.GetElapsedTime(started); - + if (left <= TimeSpan.Zero) { throw Refuse(_session); diff --git a/src/AgentCore.Application/Runtime/Session/ConversationEnding.cs b/src/AgentCore.Application/Runtime/Session/ConversationEnding.cs index 635fa158..18291a5c 100644 --- a/src/AgentCore.Application/Runtime/Session/ConversationEnding.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationEnding.cs @@ -100,6 +100,7 @@ private void StopTools() } catch (ObjectDisposedException) { + // The session is disposed, so no tool is left to stop. } finally { diff --git a/src/AgentCore.Application/Runtime/Session/ConversationStoreReads.cs b/src/AgentCore.Application/Runtime/Session/ConversationStoreReads.cs index 0eb67d4d..bf40e60b 100644 --- a/src/AgentCore.Application/Runtime/Session/ConversationStoreReads.cs +++ b/src/AgentCore.Application/Runtime/Session/ConversationStoreReads.cs @@ -1,6 +1,4 @@ using AgentCore.Application.Conversation; -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Hooks; using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; @@ -158,10 +156,9 @@ private async ValueTask OvertakenAsync( /// Whether the store holds every one of these rows, including a row a newer summary now stands in for. private async ValueTask LandedAsync(IReadOnlyList held, HashSet read, CancellationToken cancellationToken) { - foreach (string messageId in held) + foreach (string messageId in held.Where(id => !read.Contains(id))) { - if (!read.Contains(messageId) - && await _session.Compiled.ConversationStore.OrdinalOfAsync(_session.ConversationId, messageId, cancellationToken).ConfigureAwait(false) is null) + if (await _session.Compiled.ConversationStore.OrdinalOfAsync(_session.ConversationId, messageId, cancellationToken).ConfigureAwait(false) is null) { return false; } diff --git a/src/AgentCore.Application/Runtime/ToolCalls/AuditingFunctionInvokingChatClient.cs b/src/AgentCore.Application/Runtime/ToolCalls/AuditingFunctionInvokingChatClient.cs index 431b3587..af21bfdf 100644 --- a/src/AgentCore.Application/Runtime/ToolCalls/AuditingFunctionInvokingChatClient.cs +++ b/src/AgentCore.Application/Runtime/ToolCalls/AuditingFunctionInvokingChatClient.cs @@ -6,7 +6,6 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using AgentCore.Application.Runtime.Cut; -using AgentCore.Application.Runtime.Session; namespace AgentCore.Application.Runtime.ToolCalls { @@ -99,7 +98,11 @@ internal void Release(IReadOnlyList callIds) return await run.Running.ConfigureAwait(false); } - IEnumerable Attachments() => drain?.TakeFor(callId) ?? []; + IEnumerable Attachments() + { + return drain?.TakeFor(callId) ?? []; + } + object? result; try { diff --git a/src/AgentCore.Application/Runtime/ToolCalls/ConversationToolRuns.cs b/src/AgentCore.Application/Runtime/ToolCalls/ConversationToolRuns.cs index 5e0f427d..cc695c05 100644 --- a/src/AgentCore.Application/Runtime/ToolCalls/ConversationToolRuns.cs +++ b/src/AgentCore.Application/Runtime/ToolCalls/ConversationToolRuns.cs @@ -112,7 +112,11 @@ internal void Returned(ToolRun run, object? result, Func> ArgumentNullException.ThrowIfNull(attachments); ArgumentNullException.ThrowIfNull(turn); - IReadOnlyList Pair() => PairOf(run.Call, result, attachments()); + IReadOnlyList Pair() + { + return PairOf(run.Call, result, attachments()); + } + bool now; lock (_gate) { @@ -203,11 +207,13 @@ internal Task WhenCarriedDone() } } - private static IReadOnlyList PairOf(FunctionCallContent call, object? result, IEnumerable attachments) => - [ + private static IReadOnlyList PairOf(FunctionCallContent call, object? result, IEnumerable attachments) + { + return [ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(call.CallId, call.Name, call.Arguments)]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent(call.CallId, result), .. attachments]), ]; + } private void CarriedOut() { diff --git a/src/AgentCore.Application/Runtime/Turn/ConversationTurn.cs b/src/AgentCore.Application/Runtime/Turn/ConversationTurn.cs index 1fedc51a..cc12f83b 100644 --- a/src/AgentCore.Application/Runtime/Turn/ConversationTurn.cs +++ b/src/AgentCore.Application/Runtime/Turn/ConversationTurn.cs @@ -2,7 +2,6 @@ using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Runtime.Turn { diff --git a/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCommit.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCommit.cs index 304819a5..0919ee02 100644 --- a/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCommit.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCommit.cs @@ -1,4 +1,3 @@ -using AgentCore.Application.Diagnostics; using AgentCore.Application.Hooks.Engine; using AgentCore.Application.Hooks.Notices; using AgentCore.Domain; diff --git a/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCompletion.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCompletion.cs index 4d0e545c..56fe6195 100644 --- a/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCompletion.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnCompletion.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Globalization; using AgentCore.Application.Diagnostics; using AgentCore.Application.Hooks.BuiltIn; using AgentCore.Application.Hooks.Engine; diff --git a/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnStream.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnStream.cs index 0ccb65b6..0ffbc042 100644 --- a/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnStream.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/ConversationTurnStream.cs @@ -54,6 +54,7 @@ internal async Task RunTurnCoreAsync( await foreach (ChatResponseUpdate _ in RunTurnStreamingIteratorAsync(userInput, origin, result, cancellationToken) .ConfigureAwait(false)) { + // Only the result box matters here; the updates are read so the turn runs to its end. } return result.Value!; @@ -229,9 +230,12 @@ private async IAsyncEnumerable RunAsync( await stream.DisposeAsync().ConfigureAwait(false); } - result?.Value = completion.Result - ?? throw new InvalidOperationException( - $"The turn {turn.Index} of the conversation '{_session.ConversationId}' ended without a seal."); + if (result is not null) + { + result.Value = completion.Result + ?? throw new InvalidOperationException( + $"The turn {turn.Index} of the conversation '{_session.ConversationId}' ended without a seal."); + } } finally { diff --git a/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnDisposition.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnDisposition.cs index 4ae17bab..0121417f 100644 --- a/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnDisposition.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnDisposition.cs @@ -1,4 +1,3 @@ -using Microsoft.Agents.AI; using AgentCore.Application.Runtime.Agents; using AgentCore.Application.Runtime.Session; diff --git a/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnReplyResolution.cs b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnReplyResolution.cs index 447c6161..38e01b12 100644 --- a/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnReplyResolution.cs +++ b/src/AgentCore.Application/Runtime/Turn/Lifecycle/TurnReplyResolution.cs @@ -1,6 +1,4 @@ using System.Text.Json; -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Hooks; using AgentCore.Application.Hooks.Notices; using AgentCore.Domain; using Microsoft.Agents.AI; diff --git a/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs b/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs index 7538513a..21b8826b 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs @@ -88,7 +88,7 @@ internal static List FinishedMessages(IList messages) ]; if (!tools.Any(content => content is FunctionCallContent or FunctionResultContent or ToolApprovalRequestContent - || content is TextContent { Text.Length: > 0 })) + or TextContent { Text.Length: > 0 })) { // A message whose every call is still in flight, with no words beside them. It does not // belong in the next turn. diff --git a/src/AgentCore.Application/Runtime/Turn/TurnUpdateMerge.cs b/src/AgentCore.Application/Runtime/Turn/TurnUpdateMerge.cs index 07e66dfb..f704d682 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnUpdateMerge.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnUpdateMerge.cs @@ -14,14 +14,22 @@ internal static class TurnUpdateMerge /// The run's own stream. This owns and disposes its enumerator. /// This turn's notice channel. Completed once ends or fails. /// Cancels the run. - internal static async IAsyncEnumerable RunAsync( + internal static IAsyncEnumerable RunAsync( IAsyncEnumerable inner, TurnNotices notices, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(inner); ArgumentNullException.ThrowIfNull(notices); + return MergeAsync(inner, notices, cancellationToken); + } + + private static async IAsyncEnumerable MergeAsync( + IAsyncEnumerable inner, + TurnNotices notices, + [EnumeratorCancellation] CancellationToken cancellationToken) + { ChannelReader reader = notices.Reader; await using IAsyncEnumerator e = inner.GetAsyncEnumerator(cancellationToken); Task? pendingMove = null; @@ -83,10 +91,11 @@ internal static async IAsyncEnumerable RunAsync( // already unwinding on the same token, so it is settled here first. try { - await pendingMove.ConfigureAwait(false); + _ = await pendingMove.ConfigureAwait(false); } catch { + // The run's own failure or cancellation already reaches the caller; this only lets the enumerator dispose. } } } diff --git a/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs b/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs index 13dc6442..cba62b28 100644 --- a/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs +++ b/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs @@ -165,7 +165,7 @@ public async ValueTask CloseAsync(string entry, string conversationId, Cancellat /// is empty. internal void SweepWorkspaceRoot(string root, ILogger? logger) { - new WorkspaceRootSweeper(_idleTimeout, _time).Sweep(root, id => _slots.ContainsKey(id), logger); + new WorkspaceRootSweeper(_idleTimeout, _time).Sweep(root, _slots.ContainsKey, logger); } /// Stops every idle timer. A build or a close already running finishes, and wakes its waiters. diff --git a/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs b/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs index 5dde0b26..e0728b87 100644 --- a/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs +++ b/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs @@ -68,11 +68,11 @@ internal void Sweep(string root, Func isLive, ILogger? logger) // No margin for clock skew: a root shared by several servers is the host's to coordinate. DateTime cutoff = _time.GetUtcNow().UtcDateTime - _idleTimeout; - HashSet liveFolderNames = new(folders.Select(folder => Path.GetFileName(folder)!), StringComparer.Ordinal); + HashSet liveFolderNames = new(folders.Select(folder => Path.GetFileName(folder)), StringComparer.Ordinal); foreach (string folder in folders) { - if (isLive(Path.GetFileName(folder)!)) + if (isLive(Path.GetFileName(folder))) { continue; } diff --git a/src/AgentCore.Application/Transcript/FrontVoice.cs b/src/AgentCore.Application/Transcript/FrontVoice.cs index c8b2ab52..6418633b 100644 --- a/src/AgentCore.Application/Transcript/FrontVoice.cs +++ b/src/AgentCore.Application/Transcript/FrontVoice.cs @@ -13,10 +13,15 @@ internal static class FrontVoice internal const string AuthorName = "front_voice"; /// Builds one front-voice line. - internal static ChatMessage Line(string text) => new(ChatRole.Assistant, text) { AuthorName = AuthorName }; + internal static ChatMessage Line(string text) + { + return new(ChatRole.Assistant, text) { AuthorName = AuthorName }; + } /// Whether a message is the agent's own reply: an assistant message no front voice spoke. - internal static bool IsAgentReply(ChatMessage message) => - message.Role == ChatRole.Assistant && !string.Equals(message.AuthorName, AuthorName, StringComparison.Ordinal); + internal static bool IsAgentReply(ChatMessage message) + { + return message.Role == ChatRole.Assistant && !string.Equals(message.AuthorName, AuthorName, StringComparison.Ordinal); + } } } diff --git a/src/AgentCore.AspNetCore/Calls/CallAnswer.cs b/src/AgentCore.AspNetCore/Calls/CallAnswer.cs index 9861b752..dce59e8e 100644 --- a/src/AgentCore.AspNetCore/Calls/CallAnswer.cs +++ b/src/AgentCore.AspNetCore/Calls/CallAnswer.cs @@ -20,9 +20,15 @@ internal sealed record CallAnswer(CallAnswerKind Kind, string Text) { internal static CallAnswer Withdrawn { get; } = new(CallAnswerKind.Withdrawn, string.Empty); - internal static CallAnswer Answered(string text) => new(CallAnswerKind.Answered, text); + internal static CallAnswer Answered(string text) + { + return new(CallAnswerKind.Answered, text); + } /// The answer of an ask that could not run: the vendor speaks . - internal static CallAnswer FallbackOf(string fallbackReply) => new(CallAnswerKind.Fallback, fallbackReply); + internal static CallAnswer FallbackOf(string fallbackReply) + { + return new(CallAnswerKind.Fallback, fallbackReply); + } } } diff --git a/src/AgentCore.AspNetCore/Calls/CallAsks.cs b/src/AgentCore.AspNetCore/Calls/CallAsks.cs index 98269bb7..2b7a3093 100644 --- a/src/AgentCore.AspNetCore/Calls/CallAsks.cs +++ b/src/AgentCore.AspNetCore/Calls/CallAsks.cs @@ -137,7 +137,10 @@ private CallAnswer Failed(CallAsk mine, Exception fault) } // The engine's own fallback for this entry: the entry's fallbackReply, else the document's. - private CallAnswer Fallback() => CallAnswer.FallbackOf(call.Session.Compiled.FallbackReply); + private CallAnswer Fallback() + { + return CallAnswer.FallbackOf(call.Session.Compiled.FallbackReply); + } // Every ask hangs off the last message the caller heard an answer to, so a resend on the same // parent withdraws an unanswered ask, whether or not its turn filed a row yet. The first anchor is the last diff --git a/src/AgentCore.AspNetCore/Calls/PhoneCallAdmission.cs b/src/AgentCore.AspNetCore/Calls/PhoneCallAdmission.cs index a01eb10b..39e50651 100644 --- a/src/AgentCore.AspNetCore/Calls/PhoneCallAdmission.cs +++ b/src/AgentCore.AspNetCore/Calls/PhoneCallAdmission.cs @@ -13,10 +13,19 @@ internal sealed record PhoneCallAdmission(PhoneCall? Call, CallRefusal? Refusal) /// Gets whether the call was refused as busy because another call holds its conversation. internal bool HeldByCall { get; private init; } - internal static PhoneCallAdmission Admitted(PhoneCall call) => new(call, null); + internal static PhoneCallAdmission Admitted(PhoneCall call) + { + return new(call, null); + } - internal static PhoneCallAdmission Refused(CallRefusal refusal) => new(null, refusal); + internal static PhoneCallAdmission Refused(CallRefusal refusal) + { + return new(null, refusal); + } - internal static PhoneCallAdmission RefusedHeldByCall() => new(null, CallRefusal.Busy) { HeldByCall = true }; + internal static PhoneCallAdmission RefusedHeldByCall() + { + return new(null, CallRefusal.Busy) { HeldByCall = true }; + } } } diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs index d5b4cbf2..b10c22b6 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs @@ -17,7 +17,6 @@ using AgentCore.Application.Tools.Registry; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Voice.Ports; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -36,7 +35,7 @@ internal sealed class AgentCoreBoot : IAsyncDisposable, IDisposable private readonly List _opened = []; private readonly Lock _gate = new(); - + private int _closed; private BootState? _state; @@ -121,7 +120,11 @@ public AgentCoreBoot(IOptions options, ILoggerFactory loggers, private BootState Started { get => _state ?? throw NotStarted(); set => _state = value; } /// Gets the hooks, or when the boot never finished: a stop after a failed boot. - internal bool TryGetHooks([NotNullWhen(true)] out HookRuntime? hooks) => (hooks = _state?.Hooks) is not null; + internal bool TryGetHooks([NotNullWhen(true)] out HookRuntime? hooks) + { + hooks = _state?.Hooks; + return hooks is not null; + } /// Takes ownership of one resource, and hands it straight back. /// The resource's own type, so a caller loses nothing by owning it. diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreHookOptions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreHookOptions.cs index 8812b6b0..66644c29 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreHookOptions.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreHookOptions.cs @@ -35,7 +35,11 @@ public static AgentCoreOptions UseHooks(this AgentCoreOptions options, params Ag foreach (AgentHook hook in hooks) { - ArgumentNullException.ThrowIfNull(hook, nameof(hooks)); + if (hook is null) + { + throw new ArgumentNullException(nameof(hooks), "A hook in the list is null."); + } + options.HookFactories.Add((hook.GetType(), _ => hook)); } diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs index 58523c6d..9666fa85 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs @@ -5,7 +5,6 @@ using AgentCore.AspNetCore.Voice.Ports; using AgentCore.AspNetCore.Voice.Routing; using AgentCore.AspNetCore.Voice.Turns; -using Microsoft.AspNetCore.Http; namespace AgentCore.AspNetCore.DependencyInjection.Startup { diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs index 8eb30ff0..343025fa 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs @@ -25,15 +25,12 @@ internal static void Warn(AgentCoreConfiguration configuration, IReadOnlySet !servedToolIds.Contains(id)).Order(StringComparer.Ordinal)) { - if (!servedToolIds.Contains(toolId)) - { - StartupLog.FillerToolUnknown( - logger, - ConfigurationError.AppendPointer("/providers/conversation/filler", toolId), - toolId); - } + StartupLog.FillerToolUnknown( + logger, + ConfigurationError.AppendPointer("/providers/conversation/filler", toolId), + toolId); } } } diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs index cef95546..8caf078d 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs @@ -28,8 +28,10 @@ internal static class ResponsesTurnConflict /// Picks the message for a reason. /// The refusedReason of the turn's turn.refused event. /// The message the caller reads. - public static string MessageFor(string refusedReason) => - refusedReason == TurnRefusalTokens.ToToken(TurnRefusal.Busy) ? BusyMessage : ConflictMessage; + public static string MessageFor(string refusedReason) + { + return refusedReason == TurnRefusalTokens.ToToken(TurnRefusal.Busy) ? BusyMessage : ConflictMessage; + } /// /// Ends a stream whose headers already left with the Responses error event, in place of the diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs index 9e39ba31..805d76d6 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveCallEnd.cs @@ -57,7 +57,10 @@ public void Dispose() /// Marks the call finished. /// when it already was. - internal bool TryFinish() => Interlocked.Exchange(ref _finished, 1) == 0; + internal bool TryFinish() + { + return Interlocked.Exchange(ref _finished, 1) == 0; + } internal void Now(ConversationEndReason reason, string cause) { @@ -148,8 +151,10 @@ internal void WhenQuiet() } } - private ITimer QuietTimer() => - time.CreateTimer(_ => Now(ConversationEndReason.AgentCompleted, OpenAiLiveCall.AgentEndedCause), null, OpenAiLiveCall.EndQuietWait, Timeout.InfiniteTimeSpan); + private ITimer QuietTimer() + { + return time.CreateTimer(_ => Now(ConversationEndReason.AgentCompleted, OpenAiLiveCall.AgentEndedCause), null, OpenAiLiveCall.EndQuietWait, Timeout.InfiniteTimeSpan); + } private void AckLate(string eventId) { diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs index 4e672d04..77781d28 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs @@ -35,7 +35,10 @@ public ChannelCommandResult Send(ChannelCommand command) } /// Lets the call transfer. A transfer sent before this answers as not supported. - internal void Transfers(LiveTransfer transfer) => Volatile.Write(ref _transfer, transfer); + internal void Transfers(LiveTransfer transfer) + { + Volatile.Write(ref _transfer, transfer); + } /// On session.started, before the greeting: tells the context that waited, then each later one at once. /// Sends one piece of context to GPT-Live. diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHearing.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHearing.cs index 82f5e7c5..373eade3 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHearing.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHearing.cs @@ -11,9 +11,15 @@ internal sealed class LiveHearing(PhoneCall call) { private readonly LiveTranscriptLedger _ledger = new(); - internal ValueTask HearAsync(LiveEvent.Transcript delta) => RaiseAsync(_ledger.Add(delta)); + internal ValueTask HearAsync(LiveEvent.Transcript delta) + { + return RaiseAsync(_ledger.Add(delta)); + } - internal ValueTask FlushAsync() => RaiseAsync(_ledger.Flush()); + internal ValueTask FlushAsync() + { + return RaiseAsync(_ledger.Flush()); + } /// Raises the lines the delegation closed, and gives the ask its context and the caller's new words. internal async ValueTask<(IReadOnlyList Before, string Words)> TakeForDelegationAsync() diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs index fa9dda7e..29a9539c 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs @@ -65,13 +65,13 @@ internal bool TryClaimCall(string callId) internal void ReleaseCall(string callId) { - _claimed.TryRemove(callId, out _); + _ = _claimed.TryRemove(callId, out _); } /// Forgets a webhook id whose request failed, so the retry OpenAI sends under the same id is processed. internal void ReleaseWebhook(string webhookId) { - _webhooks.TryRemove(webhookId, out _); + _ = _webhooks.TryRemove(webhookId, out _); } /// diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs index 9461f336..2bb1e36a 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveSettings.cs @@ -49,7 +49,7 @@ internal static OpenAiLiveSettings From(ConversationProviderConfiguration config string? model = null; string? greeting = null; int? transferSeconds = null; - + foreach (JsonProperty property in configuration.Live.EnumerateObject()) { switch (property.Name) diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs index c7aaa3e0..2c8db2c4 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs @@ -245,7 +245,7 @@ internal async ValueTask IsOpenAiAsync(HttpContext http) return null; } - body.Write(buffer, 0, read); + await body.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); } return body.ToArray(); diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs index c87d1627..3154a008 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs @@ -46,16 +46,20 @@ private static LiveEvent Transcript(JsonElement root, Speaker speaker, string ty : new LiveEvent.Other(type); } - private static string? Text(JsonElement element, string name) => - element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + private static string? Text(JsonElement element, string name) + { + return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } - private static int? Number(JsonElement element, string name) => - element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) + private static int? Number(JsonElement element, string name) + { + return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out double number) && number is >= int.MinValue and <= int.MaxValue ? (int)number : null; + } } } diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs index 5b13788b..c1cc7724 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveControl.cs @@ -31,21 +31,33 @@ internal async Task AcceptAsync(string callId, JsonObject body, C } } - internal Task RejectAsync(string callId, CallRefusal refusal, CancellationToken cancellationToken) => - SucceedsAsync(OpenAiLiveWire.RejectPath(callId), OpenAiLiveWire.RejectBody(refusal), cancellationToken); + internal Task RejectAsync(string callId, CallRefusal refusal, CancellationToken cancellationToken) + { + return SucceedsAsync(OpenAiLiveWire.RejectPath(callId), OpenAiLiveWire.RejectBody(refusal), cancellationToken); + } - internal Task HangupAsync(string callId, CancellationToken cancellationToken) => - SucceedsAsync(OpenAiLiveWire.HangupPath(callId), [], cancellationToken); + internal Task HangupAsync(string callId, CancellationToken cancellationToken) + { + return SucceedsAsync(OpenAiLiveWire.HangupPath(callId), [], cancellationToken); + } // A 2xx says only that OpenAI relayed the REFER, not that the line took the call (probe T1). - internal Task ReferAsync(string callId, Uri target, CancellationToken cancellationToken) => - SucceedsAsync(OpenAiLiveWire.ReferPath(callId), OpenAiLiveWire.ReferBody(target), cancellationToken); + internal Task ReferAsync(string callId, Uri target, CancellationToken cancellationToken) + { + return SucceedsAsync(OpenAiLiveWire.ReferPath(callId), OpenAiLiveWire.ReferBody(target), cancellationToken); + } /// Hangs up on its own ; a deadline or a cancel gives , never an exception. - internal Task HangupQuietlyAsync(string callId) => QuietlyAsync(token => HangupAsync(callId, token)); + internal Task HangupQuietlyAsync(string callId) + { + return QuietlyAsync(token => HangupAsync(callId, token)); + } /// Rejects on its own ; a deadline or a cancel gives , never an exception. - internal Task RejectQuietlyAsync(string callId, CallRefusal refusal) => QuietlyAsync(token => RejectAsync(callId, refusal, token)); + internal Task RejectQuietlyAsync(string callId, CallRefusal refusal) + { + return QuietlyAsync(token => RejectAsync(callId, refusal, token)); + } private static async Task QuietlyAsync(Func> send) { diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs index 9a732bf4..0fecce97 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs @@ -25,16 +25,31 @@ internal static class OpenAiLiveWire internal static readonly FrozenSet IncomingCallEvents = FrozenSet.ToFrozenSet(["live.transport.incoming", "realtime.call.incoming"], StringComparer.Ordinal); // URLs as the GPT-Live docs give them. - internal static string AcceptPath(string callId) => $"v1/live/sessions/{Uri.EscapeDataString(callId)}/accept"; + internal static string AcceptPath(string callId) + { + return $"v1/live/sessions/{Uri.EscapeDataString(callId)}/accept"; + } - internal static string RejectPath(string callId) => $"v1/live/sessions/{Uri.EscapeDataString(callId)}/reject"; + internal static string RejectPath(string callId) + { + return $"v1/live/sessions/{Uri.EscapeDataString(callId)}/reject"; + } - internal static string HangupPath(string callId) => $"v1/live/sessions/{Uri.EscapeDataString(callId)}/hangup"; + internal static string HangupPath(string callId) + { + return $"v1/live/sessions/{Uri.EscapeDataString(callId)}/hangup"; + } // Proven on a real call (probe T1): the peer gets an in-dialog REFER with Refer-To set to the target. - internal static string ReferPath(string callId) => $"v1/live/sessions/{Uri.EscapeDataString(callId)}/refer"; + internal static string ReferPath(string callId) + { + return $"v1/live/sessions/{Uri.EscapeDataString(callId)}/refer"; + } - internal static JsonObject ReferBody(Uri target) => new() { ["target_uri"] = target.OriginalString }; + internal static JsonObject ReferBody(Uri target) + { + return new() { ["target_uri"] = target.OriginalString }; + } internal static Uri AttachUri(Uri apiBase, string callId) { @@ -47,27 +62,33 @@ internal static Uri AttachUri(Uri apiBase, string callId) } /// The SIP status a refusal is rejected with. - internal static int SipStatusOf(CallRefusal refusal) => refusal switch + internal static int SipStatusOf(CallRefusal refusal) { - CallRefusal.Declined => 603, - CallRefusal.Busy => 486, - _ => 503, - }; + return refusal switch + { + CallRefusal.Declined => 603, + CallRefusal.Busy => 486, + _ => 503, + }; + } /// /// The accept body: the proven session.start session shape from the probe logs, as the only field of the /// body. A real SIP call proved the wrapper: a flat body is refused with "session must be the only field". /// - internal static JsonObject AcceptBody(string model, string voice, string instructions) => new() + internal static JsonObject AcceptBody(string model, string voice, string instructions) { - ["session"] = new JsonObject + return new() { - ["model"] = model, - ["instructions"] = instructions, - ["audio"] = new JsonObject { ["output"] = new JsonObject { ["voice"] = voice } }, - ["delegation"] = new JsonObject { ["type"] = "client" }, - }, - }; + ["session"] = new JsonObject + { + ["model"] = model, + ["instructions"] = instructions, + ["audio"] = new JsonObject { ["output"] = new JsonObject { ["voice"] = voice } }, + ["delegation"] = new JsonObject { ["type"] = "client" }, + }, + }; + } /// /// The event that makes GPT-Live greet the caller instead of waiting for the caller's voice: speakable commentary @@ -75,30 +96,42 @@ internal static Uri AttachUri(Uri apiBase, string callId) /// guide sends the greeting as instructions, but GPT-Live spoke those in only 5 of 10 probe sessions (2026-10-06); /// commentary, in 8 of 8, within about a second. /// - internal static JsonObject GreetFirst(string eventId, string greeting) => new() + internal static JsonObject GreetFirst(string eventId, string greeting) { - ["type"] = OpenAiLiveEvents.CommentaryAppend, - ["event_id"] = eventId, - ["delegation_id"] = null, - ["content"] = greeting, - }; + return new() + { + ["type"] = OpenAiLiveEvents.CommentaryAppend, + ["event_id"] = eventId, + ["delegation_id"] = null, + ["content"] = greeting, + }; + } /// Words GPT-Live says at once, outside every delegation. - internal static JsonObject SayNow(string eventId, string words) => SessionFact(eventId, words); + internal static JsonObject SayNow(string eventId, string words) + { + return SessionFact(eventId, words); + } /// /// A fact for the whole session, under no delegation, from OpenAI's delegation guide: at most 500 tokens, such as /// "The current date is December 10, 2024. Today is Tuesday." /// - internal static JsonObject SessionFact(string eventId, string content) => new() + internal static JsonObject SessionFact(string eventId, string content) { - ["type"] = "session.instructions.append", - ["event_id"] = eventId, - ["delegation_id"] = null, - ["content"] = content, - }; + return new() + { + ["type"] = "session.instructions.append", + ["event_id"] = eventId, + ["delegation_id"] = null, + ["content"] = content, + }; + } - internal static JsonObject RejectBody(CallRefusal refusal) => new() { ["status_code"] = SipStatusOf(refusal) }; + internal static JsonObject RejectBody(CallRefusal refusal) + { + return new() { ["status_code"] = SipStatusOf(refusal) }; + } /// Reads an incoming-call webhook body: { type, data: { call_id | session_id, sip_headers: [ { name, value } ] } }. /// The call, or for any other event or a body that is not one. @@ -161,9 +194,11 @@ internal static Uri AttachUri(Uri apiBase, string callId) return user.Length == 0 ? null : user; } - private static string? Text(JsonElement element, string name) => - element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + private static string? Text(JsonElement element, string name) + { + return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } } } diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs index 8c165f67..e87e124b 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs @@ -32,8 +32,6 @@ internal sealed class TelnyxRelayConnection : IAsyncDisposable private readonly CancellationTokenSource _cancellation; - private readonly CancellationToken _connectionToken; - private readonly IHostApplicationLifetime _lifetime; private readonly ConnectionTaskObserver _observer; @@ -64,7 +62,7 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt http.RequestAborted, _lifetime.ApplicationStopping); - _connectionToken = _cancellation.Token; + CancellationToken connectionToken = _cancellation.Token; TimeProvider timeProvider = http.RequestServices.GetRequiredService(); @@ -87,9 +85,9 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt frameType => TelnyxRelayLog.FrameBodyRefused(_logger, frameType, ConversationIdForLog), () => TelnyxRelayLog.IdleTimeoutReached(_logger, ConversationIdForLog)), timeProvider, - _connectionToken); + connectionToken); - _sender = new JsonWebSocketSender(socket, _options.CloseTimeout, _connectionToken); + _sender = new JsonWebSocketSender(socket, _options.CloseTimeout, connectionToken); AgentCoreBoot boot = http.RequestServices.GetRequiredService(); _loop = new VoiceConversationLoop( @@ -101,7 +99,7 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt timeProvider, logger, _options.CloseTimeout, - _connectionToken, + connectionToken, _options.Voice, Replaced); } @@ -279,13 +277,16 @@ private async Task CancelAsReplacedAsync() } } - private static string RefusalDescription(CallRefusal? refusal, bool heldByCall) => refusal switch + private static string RefusalDescription(CallRefusal? refusal, bool heldByCall) { - CallRefusal.Busy when heldByCall => HeldByCallDescription, - CallRefusal.Busy => ConversationInUseDescription, - CallRefusal.Declined => "the call was declined", - _ => "the call could not be taken now", - }; + return refusal switch + { + CallRefusal.Busy when heldByCall => HeldByCallDescription, + CallRefusal.Busy => ConversationInUseDescription, + CallRefusal.Declined => "the call was declined", + _ => "the call could not be taken now", + }; + } /// Reads the ending one close status reports, as one member of the closed set. /// What already decided this conversation closes with. diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayInput.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayInput.cs index 20e99214..083884eb 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayInput.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayInput.cs @@ -102,7 +102,7 @@ private async IAsyncEnumerable ListenCoreAsync( finally { // The consumer asked for the next event, or stopped reading: either way it is done with this one. - handled.TrySetResult(); + _ = handled.TrySetResult(); } } } @@ -113,7 +113,7 @@ private async IAsyncEnumerable ListenCoreAsync( while (_inputs.Reader.TryRead(out (ConversationInput Input, TaskCompletionSource Handled) orphan)) { - orphan.Handled.TrySetResult(); + _ = orphan.Handled.TrySetResult(); } } } diff --git a/src/AgentCore.AspNetCore/Voice/Filler/ToolFillerScope.cs b/src/AgentCore.AspNetCore/Voice/Filler/ToolFillerScope.cs index f8ab9341..8d73e755 100644 --- a/src/AgentCore.AspNetCore/Voice/Filler/ToolFillerScope.cs +++ b/src/AgentCore.AspNetCore/Voice/Filler/ToolFillerScope.cs @@ -11,17 +11,12 @@ namespace AgentCore.AspNetCore.Voice.Filler { /// The filler scheduled for one tool call, open for as long as its step is still running. - internal sealed class ToolFillerScope : IAsyncDisposable + /// Where idleness, state changes, and say come from. + /// The speech this tool call's step belongs to. + /// This tool id's filler, from voice options. + internal sealed class ToolFillerScope(VoiceSession session, SpeechHandle speechHandle, FillerOptions options) : IAsyncDisposable { - private readonly FillerScheduler _scheduler; - - /// Where idleness, state changes, and say come from. - /// The speech this tool call's step belongs to. - /// This tool id's filler, from voice options. - public ToolFillerScope(VoiceSession session, SpeechHandle speechHandle, FillerOptions options) - { - _scheduler = new FillerScheduler(session, speechHandle, options.Source, options.Delay, options.Interval, options.MaxSteps); - } + private readonly FillerScheduler _scheduler = new(session, speechHandle, options.Source, options.Delay, options.Interval, options.MaxSteps); /// Gets every speech this scope's filler created, in fire order. public IReadOnlyList CreatedSpeeches => _scheduler.CreatedSpeeches; diff --git a/src/AgentCore.AspNetCore/Voice/Session/VoiceNotices.cs b/src/AgentCore.AspNetCore/Voice/Session/VoiceNotices.cs index 2470f4db..33ef59bc 100644 --- a/src/AgentCore.AspNetCore/Voice/Session/VoiceNotices.cs +++ b/src/AgentCore.AspNetCore/Voice/Session/VoiceNotices.cs @@ -9,7 +9,10 @@ namespace AgentCore.AspNetCore.Voice.Session internal static class VoiceNotices { /// The hooks of the conversation behind a port, or for a port that is not a session. - internal static SessionHooks? HooksOf(IConversationPort port) => (port as ConversationSession)?.Hooks; + internal static SessionHooks? HooksOf(IConversationPort port) + { + return (port as ConversationSession)?.Hooks; + } /// Subscribes for the life of the session. is read per change, so a rebound port is followed. internal static void Attach(VoiceSession session, Func hooks) @@ -26,20 +29,26 @@ private static void Raise(SessionHooks? hooks, VoiceParty party, VoiceState old, } } - private static VoiceState Of(AgentState state) => state switch + private static VoiceState Of(AgentState state) { - AgentState.Thinking => VoiceState.Thinking, - AgentState.Speaking => VoiceState.Speaking, - AgentState.Initializing or AgentState.Idle or AgentState.Listening => VoiceState.Listening, - _ => VoiceState.Listening, - }; + return state switch + { + AgentState.Thinking => VoiceState.Thinking, + AgentState.Speaking => VoiceState.Speaking, + AgentState.Initializing or AgentState.Idle or AgentState.Listening => VoiceState.Listening, + _ => VoiceState.Listening, + }; + } - private static VoiceState Of(UserState state) => state switch + private static VoiceState Of(UserState state) { - UserState.Speaking => VoiceState.Speaking, - UserState.Away => VoiceState.Away, - UserState.Listening => VoiceState.Listening, - _ => VoiceState.Listening, - }; + return state switch + { + UserState.Speaking => VoiceState.Speaking, + UserState.Away => VoiceState.Away, + UserState.Listening => VoiceState.Listening, + _ => VoiceState.Listening, + }; + } } } diff --git a/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs index c3cdd158..d24f5ed2 100644 --- a/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs @@ -30,7 +30,7 @@ internal sealed class EngineReplyStream private readonly ILogger _logger; private volatile Exception? _fault; - + private int? _turnIndex; private TurnCut? _cut; diff --git a/src/AgentCore.AspNetCore/Voice/Threading/AsyncEvent.cs b/src/AgentCore.AspNetCore/Voice/Threading/AsyncEvent.cs index 4a4cba05..76f586e6 100644 --- a/src/AgentCore.AspNetCore/Voice/Threading/AsyncEvent.cs +++ b/src/AgentCore.AspNetCore/Voice/Threading/AsyncEvent.cs @@ -28,7 +28,7 @@ public void Set() { lock (_gate) { - _source.TrySetResult(); + _ = _source.TrySetResult(); } } diff --git a/src/AgentCore.AspNetCore/Voice/Threading/TaskTeardown.cs b/src/AgentCore.AspNetCore/Voice/Threading/TaskTeardown.cs index a7310bb6..fe0e76eb 100644 --- a/src/AgentCore.AspNetCore/Voice/Threading/TaskTeardown.cs +++ b/src/AgentCore.AspNetCore/Voice/Threading/TaskTeardown.cs @@ -18,7 +18,7 @@ internal static class TaskTeardown /// public static async Task CancelAndWaitAsync(CancellationTokenSource cancellation, IEnumerable tasks) { - cancellation.Cancel(); + await cancellation.CancelAsync().ConfigureAwait(false); foreach (Task task in tasks) { diff --git a/src/AgentCore.AspNetCore/Voice/Turns/UserTurnHandler.cs b/src/AgentCore.AspNetCore/Voice/Turns/UserTurnHandler.cs index 8ee102de..eafde608 100644 --- a/src/AgentCore.AspNetCore/Voice/Turns/UserTurnHandler.cs +++ b/src/AgentCore.AspNetCore/Voice/Turns/UserTurnHandler.cs @@ -68,6 +68,7 @@ private async Task RunUserTurnAsync(Task? previousTurn, string transcript, long } catch (OperationCanceledException) { + // A cancelled turn is still a finished one, and only its end is waited for. } } diff --git a/src/AgentCore.AspNetCore/Voice/Turns/VoiceConversationLoop.cs b/src/AgentCore.AspNetCore/Voice/Turns/VoiceConversationLoop.cs index eac7f559..33133e21 100644 --- a/src/AgentCore.AspNetCore/Voice/Turns/VoiceConversationLoop.cs +++ b/src/AgentCore.AspNetCore/Voice/Turns/VoiceConversationLoop.cs @@ -1,8 +1,6 @@ using AgentCore.Application.Hooks.Gates; using AgentCore.Application.Hooks.Layers; using AgentCore.Application.Hooks.Notices; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Session; using AgentCore.AspNetCore.Calls; using AgentCore.AspNetCore.Voice.Diagnostics; diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs index f3efd821..2085f996 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs @@ -56,7 +56,7 @@ public async Task ATerminalStage_DatesTheEndAfterTheTurnsRowsLanded() Assert.True(turn.IsTerminal); AuditEvent ended = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.ConversationEnded); - Assert.NotNull(store.LandedAt); + _ = Assert.NotNull(store.LandedAt); Assert.True( ended.OccurredAt >= store.LandedAt, $"conversation.ended is dated {ended.OccurredAt:O}, before the turn's rows landed at {store.LandedAt:O}."); diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs index 229122f4..8a73a466 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs @@ -1,4 +1,3 @@ -using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime; diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs index bd9e2b7a..7973429f 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs @@ -1,4 +1,3 @@ -using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; using AgentCore.Application.Tests.Diagnostics; using AgentCore.Application.Tests.Runtime; diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs index 564643e1..f3b32ad2 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs @@ -1,4 +1,3 @@ -using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; using AgentCore.Application.Tests.Runtime; using AgentCore.Domain; diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs index 45087af2..08b34004 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Transcript; using AgentCore.Domain; using AgentCore.Domain.Audit; -using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Application.Runtime.Cut; diff --git a/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkRetryTests.cs b/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkRetryTests.cs index 575e3688..b7b0d7ff 100644 --- a/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkRetryTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkRetryTests.cs @@ -115,7 +115,7 @@ public async Task DisposalDuringARetryWait_EndsTheWaitWithinTheWindow_AndDropsTh _ = Assert.Single(inner.Attempts); LogLine line = Assert.Single(_logger.Of(AuditAppendFailedEventId)); - _ = Assert.IsAssignableFrom(line.Exception); + _ = Assert.IsType(line.Exception, exactMatch: false); Assert.Empty(inner.Events); } diff --git a/tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs b/tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs index 5dca130a..95e4d1dd 100644 --- a/tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs @@ -76,7 +76,7 @@ public void ANewerChannelReplacesTheOlderOne() session.Commands.Attach(newer); Assert.Equal(ChannelCommandResult.Scheduled, session.Send(new TransferCommand(Staff))); - Assert.Single(newer.Seen); + _ = Assert.Single(newer.Seen); Assert.Empty(older.Seen); } diff --git a/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreTurnConflictTests.cs b/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreTurnConflictTests.cs index c6f0ba3f..b6996ca2 100644 --- a/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreTurnConflictTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreTurnConflictTests.cs @@ -29,7 +29,7 @@ public async Task AppendAsync_ATurnAlreadySaved_IsRefused_AndWritesNothing() Exception? refused = await Record.ExceptionAsync(() => CommitAsync(store, "c1", 1, "second")); // Assert - Assert.IsType(refused); + _ = Assert.IsType(refused); IReadOnlyList rows = await store.ReadForSessionAsync("c1", Token); Assert.Equal(["opener-user", "opener-reply", "first-user", "first-reply"], rows.Select(row => row.MessageId)); ConversationRecord? record = await store.GetAsync("c1", Token); diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs index 806ef1e6..4e8f73cf 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs @@ -1,4 +1,3 @@ -using AgentCore.TestSupport; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs index 742a6de7..62d2c8c4 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs @@ -1,4 +1,3 @@ -using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; using AgentCore.Application.Diagnostics; using AgentCore.Application.Tests.Runtime; diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs index ff5022d5..7256ab74 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs @@ -7,7 +7,6 @@ using AgentCore.Application.Diagnostics; using AgentCore.Application.Hooks; using AgentCore.Application.Ports; -using AgentCore.Application.Tests.Runtime; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.Diagnostics; diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs index c43e056b..1dbbb1e4 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs @@ -1,4 +1,3 @@ -using AgentCore.TestSupport; using AgentCore.Application.Diagnostics; using AgentCore.Application.Tests.Runtime; using Microsoft.Extensions.AI; diff --git a/tests/AgentCore.Application.Tests/Fakes/HoldingConversationStore.cs b/tests/AgentCore.Application.Tests/Fakes/HoldingConversationStore.cs index 2be731f7..f6c682ee 100644 --- a/tests/AgentCore.Application.Tests/Fakes/HoldingConversationStore.cs +++ b/tests/AgentCore.Application.Tests/Fakes/HoldingConversationStore.cs @@ -42,7 +42,7 @@ public void ReleaseGets() { TaskCompletionSource? gate = _gets; _gets = null; - gate?.TrySetResult(); + _ = (gate?.TrySetResult()); } /// Disarms the gate of appends, and lets every held append go on. @@ -50,7 +50,7 @@ public void ReleaseAppends() { TaskCompletionSource? gate = _appends; _appends = null; - gate?.TrySetResult(); + _ = (gate?.TrySetResult()); } /// diff --git a/tests/AgentCore.Application.Tests/Hooks/ApprovalAnswerNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/ApprovalAnswerNoticeTests.cs index e6bfc26c..75c47b2a 100644 --- a/tests/AgentCore.Application.Tests/Hooks/ApprovalAnswerNoticeTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/ApprovalAnswerNoticeTests.cs @@ -4,7 +4,6 @@ using AgentCore.Application.Ports; using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; -using AgentCore.Application.Tests.Sessions; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Agents.AI; diff --git a/tests/AgentCore.Application.Tests/Hooks/ApprovalGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/ApprovalGateTests.cs index 030a2b59..35af2846 100644 --- a/tests/AgentCore.Application.Tests/Hooks/ApprovalGateTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/ApprovalGateTests.cs @@ -5,7 +5,6 @@ using AgentCore.Application.Hooks.Gates; using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Tests.Fakes; -using AgentCore.Application.Tests.Sessions; using AgentCore.Domain; using AgentCore.TestSupport; using Microsoft.Agents.AI; @@ -191,10 +190,15 @@ public async Task ADenialHoldsOnARunThatDoesNotStream() private sealed class FailsClosed : AgentHook { - public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + public override HookFailure FailureFor(GatePoint point) + { + return HookFailure.Closed; + } - public override ValueTask BeforeToolApprovalAsync(ApprovalGate gate, CancellationToken cancellationToken) => + public override ValueTask BeforeToolApprovalAsync(ApprovalGate gate, CancellationToken cancellationToken) + { throw new InvalidOperationException("policy store down"); + } } } } diff --git a/tests/AgentCore.Application.Tests/Hooks/ApprovalTools.cs b/tests/AgentCore.Application.Tests/Hooks/ApprovalTools.cs index c6a082cc..40db9aa7 100644 --- a/tests/AgentCore.Application.Tests/Hooks/ApprovalTools.cs +++ b/tests/AgentCore.Application.Tests/Hooks/ApprovalTools.cs @@ -35,7 +35,10 @@ internal sealed class ApprovalTools public ToolCallingChatClient Model { get; } = new("done.", new Dictionary(StringComparer.Ordinal) { ["to"] = "a@b.com" }); /// Gets how many times the named tool ran. - public int Ran(string tool) => _ran.TryGetValue(tool, out int count) ? count : 0; + public int Ran(string tool) + { + return _ran.TryGetValue(tool, out int count) ? count : 0; + } public ConversationSession Session( string yaml, @@ -58,7 +61,10 @@ public ConversationSession Session( }; } - public ApprovalRequiredAIFunction Send() => new(Counting("send_email")); + public ApprovalRequiredAIFunction Send() + { + return new(Counting("send_email")); + } private AIFunction Counting(string name) { diff --git a/tests/AgentCore.Application.Tests/Hooks/AuditHookTests.cs b/tests/AgentCore.Application.Tests/Hooks/AuditHookTests.cs index 82c6009f..a76391c1 100644 --- a/tests/AgentCore.Application.Tests/Hooks/AuditHookTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/AuditHookTests.cs @@ -17,7 +17,10 @@ public sealed class AuditHookTests private static CancellationToken Ct => TestContext.Current.CancellationToken; - private static HookScope Scope(int? turn = 0) => new("conversation-1", "main", turn, "", Guid.CreateVersion7(), 3, Moment); + private static HookScope Scope(int? turn = 0) + { + return new("conversation-1", "main", turn, "", Guid.CreateVersion7(), 3, Moment); + } private static async Task RowOfAsync(Func deliver) { @@ -33,7 +36,7 @@ public async Task ATurnCompletedIsATurnCompletedRowWithTheReplyHash() AuditEvent row = await RowOfAsync(hook => hook.OnTurnCompletedAsync(completed, Ct)); - Assert.Equal((AuditEventKind.TurnCompleted, completed.EventId, Moment, (int?)0), (row.Kind, row.EventId, row.OccurredAt, row.TurnIndex)); + Assert.Equal((AuditEventKind.TurnCompleted, completed.EventId, Moment, 0), (row.Kind, row.EventId, row.OccurredAt, row.TurnIndex)); Assert.Equal(HelloSha256, row.Payload[AuditPayloadKeys.ReplyTextSha256]); Assert.Equal(("a", "b"), (row.Payload[AuditPayloadKeys.StageBefore], row.Payload[AuditPayloadKeys.StageAfter])); AuditEventVocabulary.Validate(row); diff --git a/tests/AgentCore.Application.Tests/Hooks/CallBriefHookTests.cs b/tests/AgentCore.Application.Tests/Hooks/CallBriefHookTests.cs index 9812dec5..da8c37bc 100644 --- a/tests/AgentCore.Application.Tests/Hooks/CallBriefHookTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/CallBriefHookTests.cs @@ -16,7 +16,10 @@ public sealed class CallBriefHookTests // The agent's own instructions come first and the brief follows on a new line, so a brief that arrives // untouched, with its edge spaces, is exactly what the instructions end with after that line break. - private static bool EndsWithTheBrief(string instructions) => instructions.EndsWith("\n" + Brief, StringComparison.Ordinal); + private static bool EndsWithTheBrief(string instructions) + { + return instructions.EndsWith("\n" + Brief, StringComparison.Ordinal); + } // Instructions a run gate adds reach every round of the run. [Fact] diff --git a/tests/AgentCore.Application.Tests/Hooks/CompactionGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/CompactionGateTests.cs index a94ac6c7..25341295 100644 --- a/tests/AgentCore.Application.Tests/Hooks/CompactionGateTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/CompactionGateTests.cs @@ -124,7 +124,10 @@ public async Task ACompactionIsNamedWithItsCounts() private sealed class Throwing(HookFailure policy) : AgentHook { - public override HookFailure FailureFor(GatePoint point) => policy; + public override HookFailure FailureFor(GatePoint point) + { + return policy; + } public override ValueTask BeforeCompactionAsync(CompactionGate gate, CancellationToken cancellationToken) { diff --git a/tests/AgentCore.Application.Tests/Hooks/FlakyModel.cs b/tests/AgentCore.Application.Tests/Hooks/FlakyModel.cs index f4889708..7051be22 100644 --- a/tests/AgentCore.Application.Tests/Hooks/FlakyModel.cs +++ b/tests/AgentCore.Application.Tests/Hooks/FlakyModel.cs @@ -47,8 +47,10 @@ public async IAsyncEnumerable GetStreamingResponseAsync( yield return new ChatResponseUpdate(ChatRole.Assistant, " text") { MessageId = "m" }; } - public object? GetService(Type serviceType, object? serviceKey = null) => - serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } public void Dispose() { diff --git a/tests/AgentCore.Application.Tests/Hooks/GateOverheadTests.cs b/tests/AgentCore.Application.Tests/Hooks/GateOverheadTests.cs index eeb80653..a026d408 100644 --- a/tests/AgentCore.Application.Tests/Hooks/GateOverheadTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/GateOverheadTests.cs @@ -31,12 +31,12 @@ public async Task OneGateRunCostsMicroseconds(int hooks, double boundMicrosecond { const int Runs = 5_000; GateRunner runner = new(HookTable.Build([.. NoOps(hooks)]), NullLogger.Instance, TimeProvider.System); - await RunAsync(runner); + _ = await RunAsync(runner); long started = Stopwatch.GetTimestamp(); for (int run = 0; run < Runs; run++) { - await RunAsync(runner); + _ = await RunAsync(runner); } double mean = Stopwatch.GetElapsedTime(started).TotalMicroseconds / Runs; @@ -60,8 +60,9 @@ public async Task FiveHooksAddLittleToATurnWithOneToolCall() Assert.True(medians[2] - medians[0] < AddedPerTurnMicroseconds, $"five hooks added {medians[2] - medians[0]:0.0} µs to one turn"); } - private static ValueTask RunAsync(GateRunner runner) => - runner.RunAsync( + private static ValueTask RunAsync(GateRunner runner) + { + return runner.RunAsync( GatePoint.BeforeTool, Scope, 0, @@ -71,6 +72,7 @@ private static ValueTask RunAsync(GateRunner runner) => static state => state, raiseFault: null, CancellationToken.None); + } // The median of 50 timed turns per hook list, which is steadier than a mean. The lists take turns, so a // slow spell of the machine lands on all of them alike, and the first round is an untimed warm-up. @@ -96,17 +98,32 @@ private static async Task MedianTurnsAsync(params IReadOnlyList list.Order().ElementAt(list.Count / 2))]; } - private static IEnumerable NoOps(int count) => Enumerable.Range(0, count).Select(_ => new NoOp()); + private static IEnumerable NoOps(int count) + { + return Enumerable.Range(0, count).Select(_ => new NoOp()); + } private sealed class NoOp : AgentHook { - public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => default; + public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) + { + return default; + } - public override ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) => default; + public override ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) + { + return default; + } - public override ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) => default; + public override ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) + { + return default; + } - public override ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) => default; + public override ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) + { + return default; + } } } } diff --git a/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs b/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs index 91bc0898..1ec7f148 100644 --- a/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/GatePointTests.cs @@ -24,7 +24,7 @@ public sealed class GatePointTests }; [Theory] - [MemberData(nameof(Points))] + [MemberData(nameof(Points), DisableDiscoveryEnumeration = true)] public void EachPointHasTheSpecsDeadlineAndDefault(GatePoint point, double seconds, HookFailure failure) { Assert.Equal(TimeSpan.FromSeconds(seconds), point.Deadline); diff --git a/tests/AgentCore.Application.Tests/Hooks/GateRunnerTests.cs b/tests/AgentCore.Application.Tests/Hooks/GateRunnerTests.cs index a1397942..67537c58 100644 --- a/tests/AgentCore.Application.Tests/Hooks/GateRunnerTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/GateRunnerTests.cs @@ -196,7 +196,7 @@ public async Task AHookThatThrowsItsOwnTimeoutInTimeIsLoggedAsAFailure(bool afte TimeoutException thrown = new("the hook's own backend timed out"); Hook thrower = new(afterAnAwait ? async (_, _) => { await Task.Yield(); throw thrown; } - : (_, _) => throw thrown); + : (_, _) => throw thrown); using RecordingLoggerFactory logs = new(); GateRunner runner = new( HookTable.Build([thrower]), logs.CreateLogger("hooks"), new FakeTimeProvider(DateTimeOffset.UnixEpoch)); @@ -230,7 +230,10 @@ private static ValueTask Run(GateRunner runner, List<(Fault, AgentHook cancellationToken); } - private static Dictionary Args(string sku) => new(StringComparer.Ordinal) { ["sku"] = sku }; + private static Dictionary Args(string sku) + { + return new(StringComparer.Ordinal) { ["sku"] = sku }; + } private sealed class Hook(Func body) : AgentHook { @@ -246,7 +249,10 @@ public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cance return body(gate, cancellationToken); } - public override HookFailure FailureFor(GatePoint point) => Failure ?? base.FailureFor(point); + public override HookFailure FailureFor(GatePoint point) + { + return Failure ?? base.FailureFor(point); + } } } } diff --git a/tests/AgentCore.Application.Tests/Hooks/HookGateLazyInputTests.cs b/tests/AgentCore.Application.Tests/Hooks/HookGateLazyInputTests.cs index 18159349..9a3f646a 100644 --- a/tests/AgentCore.Application.Tests/Hooks/HookGateLazyInputTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/HookGateLazyInputTests.cs @@ -9,7 +9,10 @@ public sealed class HookGateLazyInputTests { private static readonly HookScope Scope = new("c1", "main", 0, "", Guid.CreateVersion7(), 0, DateTimeOffset.UnixEpoch); - private static RunGate Run() => new(Scope, "agent", nested: false, new Dictionary()); + private static RunGate Run() + { + return new(Scope, "agent", nested: false, new Dictionary()); + } // The runner seals a view at the deadline even while an abandoned hook is still inside a verb. The // verb stays blocked until the seal returned, so a seal that waited for it would only end at the bound. diff --git a/tests/AgentCore.Application.Tests/Hooks/HookGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/HookGateTests.cs index 551ba548..152f0c2a 100644 --- a/tests/AgentCore.Application.Tests/Hooks/HookGateTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/HookGateTests.cs @@ -9,12 +9,15 @@ public sealed class HookGateTests { private static readonly HookScope Scope = new("c1", "main", 0, "", Guid.CreateVersion7(), 0, DateTimeOffset.UnixEpoch); - private static ToolGate Tool() => new( + private static ToolGate Tool() + { + return new( Scope, "price_lookup", "call-1", new Dictionary(StringComparer.Ordinal) { ["sku"] = "A1" }, new Dictionary(StringComparer.Ordinal)); + } // Calling two terminal verbs throws. [Fact] diff --git a/tests/AgentCore.Application.Tests/Hooks/HookSessions.cs b/tests/AgentCore.Application.Tests/Hooks/HookSessions.cs index 6b90f0c4..d3581e24 100644 --- a/tests/AgentCore.Application.Tests/Hooks/HookSessions.cs +++ b/tests/AgentCore.Application.Tests/Hooks/HookSessions.cs @@ -6,7 +6,6 @@ using AgentCore.Application.Ports; using AgentCore.Application.Skills; using AgentCore.Application.Tests.Fakes; -using AgentCore.Application.Tests.Runtime; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; diff --git a/tests/AgentCore.Application.Tests/Hooks/HookTableTests.cs b/tests/AgentCore.Application.Tests/Hooks/HookTableTests.cs index 285ae68e..33fda681 100644 --- a/tests/AgentCore.Application.Tests/Hooks/HookTableTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/HookTableTests.cs @@ -64,18 +64,26 @@ public void AMethodHiddenWithNewIsNotAnOverride() private sealed class ToolOnly : AgentHook { - public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => default; + public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) + { + return default; + } } private sealed class NoticeOnly : AgentHook { - public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => default; + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) + { + return default; + } } private sealed class Hiding : AgentHook { - public new ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => + public new ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) + { throw new InvalidOperationException($"{GetType().Name} hides the gate method, so no gate calls it."); + } } } } diff --git a/tests/AgentCore.Application.Tests/Hooks/ModelGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/ModelGateTests.cs index 88903047..b9ad2dbf 100644 --- a/tests/AgentCore.Application.Tests/Hooks/ModelGateTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/ModelGateTests.cs @@ -240,13 +240,20 @@ private static async Task ReplyAsync(ConversationSession session, bool s private sealed class ClosedModelHook(bool before) : AgentHook { - public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + public override HookFailure FailureFor(GatePoint point) + { + return HookFailure.Closed; + } - public override ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) => - before ? throw new InvalidOperationException("boom") : default; + public override ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) + { + return before ? throw new InvalidOperationException("boom") : default; + } - public override ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) => - before ? default : throw new InvalidOperationException("boom"); + public override ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) + { + return before ? default : throw new InvalidOperationException("boom"); + } } internal sealed class BeforeOnly(Action before) : AgentHook diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeHubCostTests.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeHubCostTests.cs index ec20a3a2..4dab99e7 100644 --- a/tests/AgentCore.Application.Tests/Hooks/NoticeHubCostTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeHubCostTests.cs @@ -41,7 +41,10 @@ private sealed class Discarder : AgentHook { public override TimeSpan? NoticeTimeout => null; - public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => ValueTask.CompletedTask; + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } } } diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeHubTests.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeHubTests.cs index 3a132d4f..a1b92c09 100644 --- a/tests/AgentCore.Application.Tests/Hooks/NoticeHubTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeHubTests.cs @@ -13,7 +13,10 @@ public sealed class NoticeHubTests { private static readonly FakeTimeProvider Clock = new(DateTimeOffset.UnixEpoch); - private static NoticeHub Hub(params AgentHook[] hooks) => new(HookTable.Build(hooks), NullLogger.Instance, TimeProvider.System); + private static NoticeHub Hub(params AgentHook[] hooks) + { + return new(HookTable.Build(hooks), NullLogger.Instance, TimeProvider.System); + } // Raising only enqueues, so a hook that never returns cannot hold the raiser. [Fact(Timeout = 10_000)] @@ -29,7 +32,7 @@ public async Task ARaiseReturnsWhileTheHookIsStillBlocked() _ = hub.Raise(NoticeProbes.Started(turn), Clock); } - Assert.Single(blocked.Seen); + _ = Assert.Single(blocked.Seen); blocked.Hold.SetResult(); await hub.FlushAsync("c1"); Assert.Equal([0, 1, 2, 3], blocked.Seen.Select(notice => notice.Scope.TurnIndex ?? -1)); @@ -81,7 +84,7 @@ public async Task ASlowHookDoesNotDelayAnotherHook() await NoticeProbes.WaitUntilAsync(() => fast.Seen.Count == 2); await slow.Entered.Task; - Assert.Single(slow.Seen); + _ = Assert.Single(slow.Seen); slow.Hold.SetResult(); } @@ -96,7 +99,7 @@ public async Task TwoHooksThatThrowOnEverythingDoNotRaiseFaultsAboutFaults() await hub.FlushAsync("c1"); await hub.FlushAsync("c1"); - Assert.Single(recorder.Seen.OfType()); + _ = Assert.Single(recorder.Seen.OfType()); Assert.Equal(2, recorder.Seen.OfType().Count(fault => fault.Kind == FaultKind.HookFailed)); } diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeHubTimeoutTests.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeHubTimeoutTests.cs index 72016f68..5580ac8e 100644 --- a/tests/AgentCore.Application.Tests/Hooks/NoticeHubTimeoutTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeHubTimeoutTests.cs @@ -133,9 +133,15 @@ private sealed class BrokenSink : ILogger public ConcurrentQueue Written { get; } = new(); public IDisposable? BeginScope(TState state) - where TState : notnull => null; + where TState : notnull + { + return null; + } - public bool IsEnabled(LogLevel logLevel) => true; + public bool IsEnabled(LogLevel logLevel) + { + return true; + } public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { diff --git a/tests/AgentCore.Application.Tests/Hooks/NoticeProbes.cs b/tests/AgentCore.Application.Tests/Hooks/NoticeProbes.cs index b6e9ee19..d1389dca 100644 --- a/tests/AgentCore.Application.Tests/Hooks/NoticeProbes.cs +++ b/tests/AgentCore.Application.Tests/Hooks/NoticeProbes.cs @@ -7,11 +7,15 @@ namespace AgentCore.Application.Tests.Hooks /// Scopes, notices and a wait the engine tests share. internal static class NoticeProbes { - internal static HookScope Scope(string? conversationId = "c1", int? turn = 0, Guid? session = null) => - new(conversationId, "main", turn, "", session ?? Guid.Empty, 0, DateTimeOffset.UnixEpoch); + internal static HookScope Scope(string? conversationId = "c1", int? turn = 0, Guid? session = null) + { + return new(conversationId, "main", turn, "", session ?? Guid.Empty, 0, DateTimeOffset.UnixEpoch); + } - internal static TurnStarted Started(int turn = 0, Guid? session = null, string conversationId = "c1") => - new(Scope(conversationId, turn, session), "only", "hi"); + internal static TurnStarted Started(int turn = 0, Guid? session = null, string conversationId = "c1") + { + return new(Scope(conversationId, turn, session), "only", "hi"); + } /// /// Waits for a state no event announces, such as a fast hook's second delivery. It yields between checks @@ -32,13 +36,19 @@ internal static async Task WaitUntilAsync(Func condition) /// Throws on every notice it overrides, the fault notice included. internal sealed class Thrower : AgentHook { - public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) + { throw new InvalidOperationException("thrower"); + } - public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => + public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) + { throw new InvalidOperationException("thrower on end"); + } - public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) => + public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) + { throw new InvalidOperationException("thrower on fault"); + } } } diff --git a/tests/AgentCore.Application.Tests/Hooks/Recorder.cs b/tests/AgentCore.Application.Tests/Hooks/Recorder.cs index ea902917..ca279966 100644 --- a/tests/AgentCore.Application.Tests/Hooks/Recorder.cs +++ b/tests/AgentCore.Application.Tests/Hooks/Recorder.cs @@ -38,19 +38,40 @@ public IReadOnlyList Threads public override TimeSpan? NoticeTimeout => timeout; - public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) + { + return Record(notice); + } - public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) + { + return Record(notice); + } - public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) + { + return Record(notice); + } - public override ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) + { + return Record(notice); + } - public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) + { + return Record(notice); + } - public override ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) + { + return Record(notice); + } - public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) + { + return Record(notice); + } private async ValueTask Record(HookNotice notice) { diff --git a/tests/AgentCore.Application.Tests/Hooks/RunEndGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/RunEndGateTests.cs index 9a398ebe..5c9f6634 100644 --- a/tests/AgentCore.Application.Tests/Hooks/RunEndGateTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/RunEndGateTests.cs @@ -259,16 +259,22 @@ public async Task AContinuedRunIsOneToolNoticePerCallAndOneModelNoticePerRound() Assert.Equal(4, notices.Of().Count); } - private static RequestRecordingChatClient Answers() => new("answer 1", "answer 2", "answer 3", "answer 4"); + private static RequestRecordingChatClient Answers() + { + return new("answer 1", "answer 2", "answer 3", "answer 4"); + } // Adds a todo on every run, so the todo list never empties and until: todos always asks for another run. - private static ToolCallingChatClient TodoAddingModel() => new( + private static ToolCallingChatClient TodoAddingModel() + { + return new( "added", new Dictionary(StringComparer.Ordinal) { ["todos"] = new object[] { new Dictionary(StringComparer.Ordinal) { ["title"] = "T" } }, }, everyRun: true); + } /// Stamps a vendor property on every update, so each reply message carries a property bag of its own. private sealed class VendorPropertyChatClient(IChatClient inner) : DelegatingChatClient(inner) @@ -297,10 +303,15 @@ public override ValueTask AfterRunAsync(RunEndGate gate, CancellationToken cance private sealed class ClosedThrowingGate : AgentHook { - public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + public override HookFailure FailureFor(GatePoint point) + { + return HookFailure.Closed; + } - public override ValueTask AfterRunAsync(RunEndGate gate, CancellationToken cancellationToken) => + public override ValueTask AfterRunAsync(RunEndGate gate, CancellationToken cancellationToken) + { throw new InvalidOperationException("broken"); + } } } } diff --git a/tests/AgentCore.Application.Tests/Hooks/RunFactNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/RunFactNoticeTests.cs index 4dfdadcc..fab1526e 100644 --- a/tests/AgentCore.Application.Tests/Hooks/RunFactNoticeTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/RunFactNoticeTests.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Nodes; -using AgentCore.Application.Blobs; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; @@ -166,7 +164,10 @@ private sealed class ThrowingCitations : IKnowledgeCitationFormatter { public string Name => "throwing"; - public string? Format(KnowledgeCard card) => throw new InvalidOperationException("no label"); + public string? Format(KnowledgeCard card) + { + throw new InvalidOperationException("no label"); + } } } } diff --git a/tests/AgentCore.Application.Tests/Hooks/RunGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/RunGateTests.cs index 27a64bc0..d3132afe 100644 --- a/tests/AgentCore.Application.Tests/Hooks/RunGateTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/RunGateTests.cs @@ -114,7 +114,7 @@ public async Task AToolAddedByAHookIsOfferedOncePerRoundAcrossALoopsIterations() _ = await session.RunTurnAsync("fix it", Ct); - Assert.Equal(2, fired); + Assert.Equal(2, fired); Assert.All(model.Requests, request => Assert.Equal(1, request.Tools.Count(name => name == "extra_tool"))); } @@ -123,7 +123,7 @@ public async Task AToolAddedByAHookIsOfferedOncePerRoundAcrossALoopsIterations() public async Task AHookThatThrowsAfterStagingAddsNothingAndTheNextHookStillDoes() { RequestsSeen model = new(new ScriptedChatClient("done")); - Gate failing = new(gate => StageAllThenThrow(gate)); + Gate failing = new(StageAllThenThrow); Gate healthy = new(gate => StageAll(gate, "healthy")); ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, model, [failing, healthy]); diff --git a/tests/AgentCore.Application.Tests/Hooks/SessionFaultNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/SessionFaultNoticeTests.cs index aec8dcc3..549bf918 100644 --- a/tests/AgentCore.Application.Tests/Hooks/SessionFaultNoticeTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/SessionFaultNoticeTests.cs @@ -44,7 +44,7 @@ public async Task AStoredSlotTheDocumentNoLongerDeclaresIsAPartialRestore() await session.FlushNoticesAsync(); Fault fault = Assert.Single(hook.Of()); - Assert.Equal((FaultKind.StateRestorePartial, (int?)null), (fault.Kind, fault.Scope.TurnIndex)); + Assert.Equal((FaultKind.StateRestorePartial, null), (fault.Kind, fault.Scope.TurnIndex)); Assert.Equal("the document no longer declares the slot 'retired'.", fault.Message); } @@ -66,7 +66,7 @@ public async Task AnExtractorReplyThatIsNotJsonIsAFailedExtraction() await session.FlushNoticesAsync(); Fault fault = Assert.Single(hook.Of()); - Assert.Equal((FaultKind.ExtractionFailed, (int?)0), (fault.Kind, fault.Scope.TurnIndex)); + Assert.Equal((FaultKind.ExtractionFailed, 0), (fault.Kind, fault.Scope.TurnIndex)); Assert.StartsWith("the extractor reply is not well-formed JSON", fault.Message, StringComparison.Ordinal); } @@ -83,7 +83,7 @@ public async Task AStoreThatCannotPutTheBusyMarkIsAFailedMarkAndTheTurnStillRuns Assert.Equal("hello", turn.ReplyText); Fault fault = Assert.Single(hook.Of()); - Assert.Equal((FaultKind.BusyMarkFailed, (int?)null), (fault.Kind, fault.Scope.TurnIndex)); + Assert.Equal((FaultKind.BusyMarkFailed, null), (fault.Kind, fault.Scope.TurnIndex)); Assert.Equal("IOException: the store is down", fault.Message); _ = Assert.IsType(fault.Cause); } @@ -93,7 +93,7 @@ public async Task AMarkAnotherSessionTookWhileTheTurnRanIsALostMark() { Fault fault = await RenewAsync(() => false); - Assert.Equal((FaultKind.BusyMarkLost, (int?)null), (fault.Kind, fault.Scope.TurnIndex)); + Assert.Equal((FaultKind.BusyMarkLost, null), (fault.Kind, fault.Scope.TurnIndex)); Assert.Null(fault.Cause); } @@ -102,7 +102,7 @@ public async Task ARenewalTheStoreFailsIsAFailedMark() { Fault fault = await RenewAsync(() => throw new IOException("the store is down")); - Assert.Equal((FaultKind.BusyMarkFailed, (int?)null), (fault.Kind, fault.Scope.TurnIndex)); + Assert.Equal((FaultKind.BusyMarkFailed, null), (fault.Kind, fault.Scope.TurnIndex)); _ = Assert.IsType(fault.Cause); } diff --git a/tests/AgentCore.Application.Tests/Hooks/SessionHooksTests.cs b/tests/AgentCore.Application.Tests/Hooks/SessionHooksTests.cs index f2d95913..6d17adbe 100644 --- a/tests/AgentCore.Application.Tests/Hooks/SessionHooksTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/SessionHooksTests.cs @@ -6,7 +6,6 @@ using AgentCore.Application.Hooks.Gates; using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Tests.Fakes; -using AgentCore.Application.Tests.Runtime; using AgentCore.Application.Tests.Transcript; using AgentCore.TestSupport; using Xunit; @@ -243,7 +242,10 @@ public override async ValueTask OnConversationStartedAsync(ConversationStarted n private sealed class ToolGateHook : AgentHook { - public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => default; + public override ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) + { + return default; + } } } } diff --git a/tests/AgentCore.Application.Tests/Hooks/TelemetryHookTests.cs b/tests/AgentCore.Application.Tests/Hooks/TelemetryHookTests.cs index 8b4f2dd8..80bc8200 100644 --- a/tests/AgentCore.Application.Tests/Hooks/TelemetryHookTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/TelemetryHookTests.cs @@ -31,7 +31,7 @@ private sealed record Reading(string Instrument, string Key, string Value); }; [Theory] - [MemberData(nameof(FailedTurns))] + [MemberData(nameof(FailedTurns), DisableDiscoveryEnumeration = true)] public async Task AFailedTurnIsCountedUnderItsKind(TurnCompleted completed, string kind) { List measured = await MeasureAsync(hook => hook.OnTurnCompletedAsync(completed, TestContext.Current.CancellationToken)); @@ -132,8 +132,10 @@ public async Task EachLatencyIsRecordedOnItsSignedOffHistogram(LatencyMetric met Assert.Contains((instrument, 0.25), readings); } - private static TurnCompleted Turn(TurnOutcome outcome, bool inTool) => - new(Scope, outcome, "hi", "x", "", "", TimeSpan.Zero, outcome == TurnOutcome.Answered ? null : "why", inTool, null); + private static TurnCompleted Turn(TurnOutcome outcome, bool inTool) + { + return new(Scope, outcome, "hi", "x", "", "", TimeSpan.Zero, outcome == TurnOutcome.Answered ? null : "why", inTool, null); + } private static async Task> MeasureAsync(Func deliver) { diff --git a/tests/AgentCore.Application.Tests/Hooks/ToolCalledNoticeTests.cs b/tests/AgentCore.Application.Tests/Hooks/ToolCalledNoticeTests.cs index fe9eeedc..c886c625 100644 --- a/tests/AgentCore.Application.Tests/Hooks/ToolCalledNoticeTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/ToolCalledNoticeTests.cs @@ -70,7 +70,7 @@ public async Task ATimeLimitMarksTheCallTimedOut() { AIFunction slow = AIFunctionFactory.Create(async (CancellationToken token) => { await Task.Delay(Timeout.Infinite, token); return "late"; }, "slow"); TimeLimitedTool limited = new(slow, TimeSpan.FromMilliseconds(20)); - AIFunctionArguments arguments = new(); + AIFunctionArguments arguments = []; _ = await limited.InvokeAsync(arguments, Ct); @@ -85,8 +85,8 @@ public async Task ACachedAnswerMarksTheCallCached() HybridCache cache = services.BuildServiceProvider().GetRequiredService(); CachedTool cached = new(AIFunctionFactory.Create(() => "fresh", "lookup"), cache, TimeSpan.FromMinutes(1)); - AIFunctionArguments first = new(); - AIFunctionArguments second = new(); + AIFunctionArguments first = []; + AIFunctionArguments second = []; _ = await cached.InvokeAsync(first, Ct); _ = await cached.InvokeAsync(second, Ct); diff --git a/tests/AgentCore.Application.Tests/Hooks/ToolGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/ToolGateTests.cs index e2c925a1..9df88a9b 100644 --- a/tests/AgentCore.Application.Tests/Hooks/ToolGateTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/ToolGateTests.cs @@ -1,4 +1,3 @@ -using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Hooks; using AgentCore.Application.Hooks.Gates; using AgentCore.Application.Hooks.Notices; @@ -217,10 +216,15 @@ public async Task AnAfterToolHookThatFailsClosedWithholdsTheResult() private sealed class ClosedAfterTool : AgentHook { - public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + public override HookFailure FailureFor(GatePoint point) + { + return HookFailure.Closed; + } - public override ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) => + public override ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) + { throw new InvalidOperationException("boom"); + } } private sealed class Gate( diff --git a/tests/AgentCore.Application.Tests/Hooks/TurnGateTests.cs b/tests/AgentCore.Application.Tests/Hooks/TurnGateTests.cs index d34dc309..cfd66992 100644 --- a/tests/AgentCore.Application.Tests/Hooks/TurnGateTests.cs +++ b/tests/AgentCore.Application.Tests/Hooks/TurnGateTests.cs @@ -141,10 +141,15 @@ public async Task AddedContextBelongsToItsTurnOnly() private sealed class ClosedGate : AgentHook { - public override HookFailure FailureFor(GatePoint point) => HookFailure.Closed; + public override HookFailure FailureFor(GatePoint point) + { + return HookFailure.Closed; + } - public override ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) => + public override ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) + { throw new InvalidOperationException("boom"); + } } private sealed class Gate(Action decide) : AgentHook diff --git a/tests/AgentCore.Application.Tests/Hooks/UsageModel.cs b/tests/AgentCore.Application.Tests/Hooks/UsageModel.cs index e2f3bdea..a12c43da 100644 --- a/tests/AgentCore.Application.Tests/Hooks/UsageModel.cs +++ b/tests/AgentCore.Application.Tests/Hooks/UsageModel.cs @@ -6,13 +6,15 @@ namespace AgentCore.Application.Tests.Hooks /// Streams one text update and one usage update. internal sealed class UsageModel : IChatClient { - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => - Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "hi")) + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "hi")) { ModelId = "test-model", FinishReason = ChatFinishReason.Stop, Usage = new UsageDetails { InputTokenCount = 11, OutputTokenCount = 3, CachedInputTokenCount = 5 }, }); + } public async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -24,8 +26,10 @@ public async IAsyncEnumerable GetStreamingResponseAsync( } } - public object? GetService(Type serviceType, object? serviceKey = null) => - serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } public void Dispose() { diff --git a/tests/AgentCore.Application.Tests/Hooks/WarningThrowingLogger.cs b/tests/AgentCore.Application.Tests/Hooks/WarningThrowingLogger.cs index 5f9f5548..72787430 100644 --- a/tests/AgentCore.Application.Tests/Hooks/WarningThrowingLogger.cs +++ b/tests/AgentCore.Application.Tests/Hooks/WarningThrowingLogger.cs @@ -6,11 +6,19 @@ namespace AgentCore.Application.Tests.Hooks internal sealed class WarningThrowingLogger : ILogger { public IDisposable? BeginScope(TState state) - where TState : notnull => null; + where TState : notnull + { + return null; + } - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= LogLevel.Warning; + } - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { throw new InvalidOperationException("the logger is down"); + } } } diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs index 334af7ad..39729548 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs @@ -13,7 +13,6 @@ using Microsoft.Extensions.Logging; using Xunit; using AgentCore.Application.Runtime.Clarification; -using AgentCore.Application.Runtime.ToolCalls; namespace AgentCore.Application.Tests.Knowledge { diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs index daca742b..a0217a60 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Tests.Knowledge.Fakes; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; using Xunit; using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs index 95673c31..34901e4a 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Tests.Knowledge.Fakes; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; using Xunit; using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryModeTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryModeTests.cs index 78d58c7c..609b51ea 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryModeTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryModeTests.cs @@ -4,7 +4,6 @@ using AgentCore.Application.Tests.Knowledge.Fakes; using AgentCore.TestSupport; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Xunit; using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryScopeTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryScopeTests.cs index a7f529c1..d74c73f1 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryScopeTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryScopeTests.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Tests.Knowledge.Fakes; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; using Xunit; using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs index 76d34402..22de979b 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs @@ -43,7 +43,7 @@ internal static AIContextProvider Provider(IKnowledgeRetrievalPort port, Resolve { return KnowledgeProviderFactory.Create( port, knowledge, "agent-under-test", new SourceLocatorCitationFormatter(), loggers: null); - } + } /// Every message text of a returned context, in one string. internal static string Merged(AIContext context) diff --git a/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs b/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs index c09bac0f..1e0f18d0 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs @@ -35,12 +35,15 @@ public sealed class AgentDelegationBackgroundReleaseTests private static CancellationToken Ct => TestContext.Current.CancellationToken; - private static Dictionary StartArgs() => new(StringComparer.Ordinal) + private static Dictionary StartArgs() { - ["agentName"] = "blocker", - ["input"] = "work", - ["description"] = "d", - }; + return new(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }; + } [Fact] public async Task Delegation_ToAnAgentWithABackgroundChild_ReleasesItWhenTheToolReturns() diff --git a/tests/AgentCore.Application.Tests/Runtime/CallForever.cs b/tests/AgentCore.Application.Tests/Runtime/CallForever.cs index da0998ef..e2d6edd7 100644 --- a/tests/AgentCore.Application.Tests/Runtime/CallForever.cs +++ b/tests/AgentCore.Application.Tests/Runtime/CallForever.cs @@ -40,7 +40,10 @@ public async Task GetResponseAsync(IEnumerable messag return updates.ToChatResponse(); } - public object? GetService(Type serviceType, object? serviceKey = null) => null; + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } public void Dispose() { diff --git a/tests/AgentCore.Application.Tests/Runtime/Compaction/ConversationTurnStreamCompactionNoticeTests.cs b/tests/AgentCore.Application.Tests/Runtime/Compaction/ConversationTurnStreamCompactionNoticeTests.cs index 1e24dfcd..41488de6 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Compaction/ConversationTurnStreamCompactionNoticeTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Compaction/ConversationTurnStreamCompactionNoticeTests.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Runtime.Compaction; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Transcript; -using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Application.Runtime.Session; @@ -122,7 +121,10 @@ public IAsyncEnumerable GetStreamingResponseAsync( throw new NotSupportedException("Summarisation runs the buffered path only."); } - public object? GetService(Type serviceType, object? serviceKey = null) => null; + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } public void Dispose() { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs index 011794a1..745bb929 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs @@ -99,7 +99,7 @@ public async Task ACallerThatLeavesWhileTheTurnWaits_DropsTheTurn_WithAWarningAn Exception? dropped = await Record.ExceptionAsync(() => turn); // Assert - _ = Assert.IsAssignableFrom(dropped); + _ = Assert.IsType(dropped, exactMatch: false); Assert.Empty(model.Requests); AuditEvent refused = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.TurnRefused); Assert.Equal(("gone", null), (refused.Payload[AuditPayloadKeys.RefusedReason], refused.TurnIndex)); diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs index 99afc5a4..31301282 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs @@ -97,7 +97,7 @@ public async Task AnInterruptionDuringABufferedTurn_CutsThatTurnWithNothingShown Assert.Equal(1, second.TurnIndex); Assert.Equal(string.Empty, second.ReplyText); - Assert.NotNull(second.Cut); + _ = Assert.NotNull(second.Cut); Assert.Equal(["hi", "hello there.", "go on"], session.Transcript.Select(message => message.Text)); Assert.Equal(2, session.State.TurnIndex); } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs index 7cc38ba5..d0b0203c 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs @@ -4,7 +4,6 @@ using AgentCore.Application.Tests.Fakes; using AgentCore.Domain; using AgentCore.Domain.Audit; -using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Application.Runtime.Cut; @@ -95,7 +94,7 @@ public async Task CutBeforeAnyContent_KeepsTheUserMessageOnly_AndLeavesThePrevio // Act bool recorded = session.Cut(1, new TurnCut(string.Empty, null)); - model.Release.TrySetResult(); + _ = model.Release.TrySetResult(); await drain; await session.FlushTranscriptAsync(); diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs index 6392aeea..6ef75aac 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Tests.Audit; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain.Audit; -using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; using AgentCore.Application.Runtime.Cut; diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditApprovalTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditApprovalTests.cs index e2970bdf..b47d7863 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditApprovalTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditApprovalTests.cs @@ -70,7 +70,7 @@ public async Task AnEditOfATurnWhoseRequestsAreOpenLeavesNoAnswerWithoutItsReque ? new ApprovalRequiredAIFunction(AIFunctionFactory.Create((string to) => { ran++; return "sent"; }, declared.Id, declared.Id)) : null); TurnResult afterReload = await reloaded.RunTurnAsync("still there?", Ct); - Assert.Equal((0, (string?)null, (string?)null, 0), (orphanAnswers.Count, next.Failure, afterReload.Failure, ran)); + Assert.Equal((0, null, null, 0), (orphanAnswers.Count, next.Failure, afterReload.Failure, ran)); Assert.DoesNotContain(hook.Of(), changed => changed.State == ApprovalState.Denied && changed.Scope.TurnIndex == edited.TurnIndex); } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs index f117551b..dd9f9199 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs @@ -3,8 +3,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Hooks; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; using AgentCore.TestSupport; using Microsoft.Extensions.AI; diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs index 554c6ac1..39d3fd66 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs @@ -137,9 +137,14 @@ public async IAsyncEnumerable GetStreamingResponseAsync( } public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new TaskCanceledException("HttpClient.Timeout of 100 seconds elapsing.", new TimeoutException()); + { + throw new TaskCanceledException("HttpClient.Timeout of 100 seconds elapsing.", new TimeoutException()); + } - public object? GetService(Type serviceType, object? serviceKey = null) => null; + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } public void Dispose() { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs index 410ef714..400f126d 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs @@ -152,7 +152,7 @@ public async Task Fault_ConcurrentGraphWithNoReusedSession_SpeaksTheFallbackWith } finally { - slow.Open.TrySetResult(); + _ = slow.Open.TrySetResult(); } } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs index 9bf3f4d7..a3edeb16 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs @@ -1,6 +1,5 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; -using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Conversation.Memory; using AgentCore.Domain; @@ -145,7 +144,9 @@ public async Task Graph_TwoCompilesAlternating_EveryTurnResumes(string yaml) private sealed class TextModel(string text) : IChatClient { public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text))); + { + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text))); + } public async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, @@ -157,7 +158,10 @@ public async IAsyncEnumerable GetStreamingResponseAsync( yield return new ChatResponseUpdate(ChatRole.Assistant, text) { ResponseId = id, MessageId = id }; } - public object? GetService(Type serviceType, object? serviceKey = null) => null; + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } public void Dispose() { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs index 515e728f..8c70c935 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs @@ -6,7 +6,6 @@ using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Tests.Audit; using AgentCore.Application.Tests.Fakes; -using AgentCore.Application.Tests.Transcript; using AgentCore.Domain; using AgentCore.Domain.Audit; using AgentCore.TestSupport; diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionParallelApprovalTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionParallelApprovalTests.cs index a6fba153..11dec89a 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionParallelApprovalTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionParallelApprovalTests.cs @@ -1,4 +1,3 @@ -using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Hooks; using AgentCore.Application.Hooks.Gates; diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs index 2f13e27c..f9109650 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs @@ -154,7 +154,7 @@ public async Task ATurnStartedWhileAnotherRunsPastTheWaitLimit_IsRefusedAsBusy() // Assert _ = await Assert.ThrowsAsync(() => starting); AuditEvent refused = Assert.Single(await session.RowsAsync(sink), item => item.Kind == AuditEventKind.TurnRefused); - Assert.Equal(((int?)null, "busy"), (refused.TurnIndex, refused.Payload[AuditPayloadKeys.RefusedReason])); + Assert.Equal((null, "busy"), (refused.TurnIndex, refused.Payload[AuditPayloadKeys.RefusedReason])); } /// Subscribes to the one activity source of this library, and no other. diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs index 4d33e54c..f8df1c51 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Tests.Fakes; -using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; @@ -54,7 +53,7 @@ public async Task CutBeforeOutput_KeepsTheUserOnly_AndLeavesThePreviousTurnUntou Task> drain = h.DrainAsync(second); await model.Gated.Task.WaitAsync(Ct); _ = second.Slot.TryCut(new TurnCut(string.Empty, null)); - model.Release.TrySetResult(); + _ = model.Release.TrySetResult(); _ = await drain; // Assert diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs index 5d564b95..e965fdd0 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Tests.Evaluation.Fakes; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; -using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs index e223e91c..4831b3be 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs @@ -1,7 +1,6 @@ using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; -using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs index fd40eead..ee2e9fe4 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Tests.Evaluation.Fakes; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Transcript; -using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs index 9f36337f..aeaab295 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs @@ -55,10 +55,10 @@ public async Task TheMarkerDoesNotShowUpInTheMemoryIndex() public void GitCloneIntoTheWorkspaceSucceeds() { string src = Directory.CreateDirectory(Path.Combine(_root, "src-repo")).FullName; - Run(src, "git", "init", "-q"); + _ = Run(src, "git", "init", "-q"); File.WriteAllText(Path.Combine(src, "a.txt"), "a"); - Run(src, "git", "-c", "user.email=a@b", "-c", "user.name=a", "add", "."); - Run(src, "git", "-c", "user.email=a@b", "-c", "user.name=a", "commit", "-qm", "x"); + _ = Run(src, "git", "-c", "user.email=a@b", "-c", "user.name=a", "add", "."); + _ = Run(src, "git", "-c", "user.email=a@b", "-c", "user.name=a", "commit", "-qm", "x"); ConversationWorkspace workspace = ConversationWorkspace.Create(_root, "c1"); diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs index 6c6ad805..a1d5cce4 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs @@ -136,7 +136,7 @@ public void Create_WhenTheMarkerPathIsADirectory_StillMakesTheFolderAndLogsAWarn { // The marker write must never be load-bearing for the conversation getting a working folder: a // collision at the marker's path only means the boot sweep will never consider this folder. - Directory.CreateDirectory(ConversationWorkspace.MarkerPathFor(Path.Combine(_root, "conversation-1"))); + _ = Directory.CreateDirectory(ConversationWorkspace.MarkerPathFor(Path.Combine(_root, "conversation-1"))); using RecordingLoggerFactory loggers = new(); ILogger logger = loggers.CreateLogger("x"); diff --git a/tests/AgentCore.Application.Tests/Runtime/CutTurnBackgroundToolTests.cs b/tests/AgentCore.Application.Tests/Runtime/CutTurnBackgroundToolTests.cs index 573b16ac..ca529335 100644 --- a/tests/AgentCore.Application.Tests/Runtime/CutTurnBackgroundToolTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/CutTurnBackgroundToolTests.cs @@ -225,8 +225,9 @@ private static ConversationSession Create(IChatClient model, GatedTool tool, Age time: time); } - private static List Shapes(IEnumerable transcript) => - [ + private static List Shapes(IEnumerable transcript) + { + return [ .. transcript.Select(message => message.Contents switch { var contents when contents.OfType().Any() => "call", @@ -234,6 +235,7 @@ var contents when contents.OfType().Any() => "result", _ => message.Text, }), ]; + } private static Task ReadAllAsync(TurnRun run) { diff --git a/tests/AgentCore.Application.Tests/Runtime/FailsOnceThenAnswersModel.cs b/tests/AgentCore.Application.Tests/Runtime/FailsOnceThenAnswersModel.cs index 0c1e70c6..260a3e96 100644 --- a/tests/AgentCore.Application.Tests/Runtime/FailsOnceThenAnswersModel.cs +++ b/tests/AgentCore.Application.Tests/Runtime/FailsOnceThenAnswersModel.cs @@ -32,7 +32,10 @@ public async Task GetResponseAsync(IEnumerable messag return updates.ToChatResponse(); } - public object? GetService(Type serviceType, object? serviceKey = null) => null; + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } public void Dispose() { diff --git a/tests/AgentCore.Application.Tests/Runtime/GraphParticipantAgentTests.cs b/tests/AgentCore.Application.Tests/Runtime/GraphParticipantAgentTests.cs index 05f09e88..916e1362 100644 --- a/tests/AgentCore.Application.Tests/Runtime/GraphParticipantAgentTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/GraphParticipantAgentTests.cs @@ -57,7 +57,10 @@ public async Task AFilePublishedBeforeTheRunThrowsStillReachesTheCaller() Assert.Equal(("rows.csv", "publisher"), (card.File.Name, card.Author)); } - private static (string?, int?, bool?) Facts(TurnInvocation? turn) => (turn?.ConversationId, turn?.TurnIndex, turn?.Nested); + private static (string?, int?, bool?) Facts(TurnInvocation? turn) + { + return (turn?.ConversationId, turn?.TurnIndex, turn?.Nested); + } /// Publishes a file the way file.publish files it, under its tool call, then fails the run. private sealed class PublishThenThrowAgent : AIAgent diff --git a/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs b/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs index 5bc313ac..6946d90b 100644 --- a/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs +++ b/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Transcript; -using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedToolThenReplyChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedToolThenReplyChatClient.cs index 22dd452f..cfc96916 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedToolThenReplyChatClient.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedToolThenReplyChatClient.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; namespace AgentCore.Application.Tests.Runtime diff --git a/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs b/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs index 37801196..2797ddb8 100644 --- a/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs +++ b/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs @@ -11,19 +11,27 @@ namespace AgentCore.Application.Tests.Runtime internal sealed class RawEventAgent(WorkflowEvent raw) : AIAgent { protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + { + throw new NotSupportedException(); + } protected override ValueTask SerializeSessionCoreAsync( AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + { + throw new NotSupportedException(); + } protected override ValueTask DeserializeSessionCoreAsync( JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + { + throw new NotSupportedException(); + } protected override Task RunCoreAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + { + throw new NotSupportedException(); + } protected override async IAsyncEnumerable RunCoreStreamingAsync( IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) diff --git a/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs b/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs index 32385c0a..f9e27730 100644 --- a/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/RunningToolInterruptTests.cs @@ -63,7 +63,7 @@ public async Task ACorrectionWhileAToolRunsLetsItFinishOnceAndTheNewTurnReadsIts Assert.Equal((1, 1), (tool.Runs, tool.Finished)); Assert.Equal(Answer, newer.ReplyText); - Assert.Contains(model.Requests[^1], message => HasResult(message)); + Assert.Contains(model.Requests[^1], HasResult); Assert.Equal(["hello", Answer, Combined, "call", "result", Answer], Shapes(session.Transcript)); _ = Assert.Single(hook.Of()); Assert.Equal(ToolOutcome.Ok, Assert.Single(hook.Of()).Outcome); @@ -93,7 +93,7 @@ public async Task ACutWhileAToolRunsLetsItFinishAndKeepsItsCallAndResult() _ = await session.RunTurnAsync(Later, Ct); Assert.Equal((1, 1), (tool.Runs, tool.Finished)); - Assert.Contains(model.Requests[^1], message => HasResult(message)); + Assert.Contains(model.Requests[^1], HasResult); Assert.Equal([Question, "call", "result", Later, Answer], Shapes(session.Transcript)); await session.FlushNoticesAsync(); Assert.Equal(ToolOutcome.Ok, Assert.Single(hook.Of()).Outcome); @@ -242,11 +242,14 @@ private static ConversationSession Create(IChatClient model, GatedTool tool, Age time: time); } - private static bool HasResult(ChatMessage message) => - message.Contents.OfType().Any(result => result.Result?.ToString() == GatedTool.Result); + private static bool HasResult(ChatMessage message) + { + return message.Contents.OfType().Any(result => result.Result?.ToString() == GatedTool.Result); + } - private static List Shapes(IEnumerable transcript) => - [ + private static List Shapes(IEnumerable transcript) + { + return [ .. transcript.Select(message => message.Contents switch { var contents when contents.OfType().Any() => "call", @@ -254,6 +257,7 @@ var contents when contents.OfType().Any() => "result", _ => message.Text, }), ]; + } // A run the host cancelled ends in its cancellation; one whose model never read the token ends on its own. private static async Task EndedAsync(Task reading) diff --git a/tests/AgentCore.Application.Tests/Runtime/RuntimeFakes.cs b/tests/AgentCore.Application.Tests/Runtime/RuntimeFakes.cs index ccce9e15..1476d334 100644 --- a/tests/AgentCore.Application.Tests/Runtime/RuntimeFakes.cs +++ b/tests/AgentCore.Application.Tests/Runtime/RuntimeFakes.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Tools; using Microsoft.Extensions.AI; -using AgentCore.Application.Runtime.ToolCalls; namespace AgentCore.Application.Tests.Runtime { diff --git a/tests/AgentCore.Application.Tests/Runtime/ToolCancelOutsideTurnTests.cs b/tests/AgentCore.Application.Tests/Runtime/ToolCancelOutsideTurnTests.cs index 52040a66..e39177e0 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ToolCancelOutsideTurnTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ToolCancelOutsideTurnTests.cs @@ -48,7 +48,7 @@ public async Task ARunOutsideATurnStillCancelsItsRunningToolWhenTheCallerCancels Task first = await Task.WhenAny(tool.Cancelled.Task, Task.Delay(TimeSpan.FromSeconds(3), Ct)); bool cancelled = first == tool.Cancelled.Task; - tool.Release.TrySetResult(); + _ = tool.Release.TrySetResult(); try { _ = await run; @@ -100,7 +100,7 @@ public async Task ABackgroundChildsRunningToolIsCancelledThirtySecondsAfterTheCo Task first = await Task.WhenAny(tool.Cancelled.Task, Task.Delay(TimeSpan.FromSeconds(3), Ct)); bool cancelled = first == tool.Cancelled.Task; - tool.Release.TrySetResult(); + _ = tool.Release.TrySetResult(); await session.DisposeAsync(); Assert.True(cancelled, "the conversation ended 30 s ago, yet the background child's tool still ran"); diff --git a/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs b/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs index 90048608..367c900a 100644 --- a/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs @@ -53,7 +53,7 @@ public async Task AToolFaultsMarkerMessage_NeverReachesTheSpan_AndAlwaysReachesT LogLine line = Assert.Single(logger.Of(2)); Assert.DoesNotContain(ThrowingToolBuilder.Message, line.Message, StringComparison.Ordinal); Assert.NotNull(line.Exception); - Assert.IsType(line.Exception); + _ = Assert.IsType(line.Exception); Assert.Contains(ThrowingToolBuilder.Message, line.Exception!.Message, StringComparison.Ordinal); } diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs index bb02e4ad..4eea9012 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs @@ -153,7 +153,7 @@ public async Task ABuildThatThrowsFaultsEveryWaiterAndLeavesTheIdFreeForARetry() factory.Release(); Assert.Same(fault, await Assert.ThrowsAsync(() => first)); - Assert.Same(fault, await Assert.ThrowsAsync(() => waiter.AsTask())); + Assert.Same(fault, await Assert.ThrowsAsync(waiter.AsTask)); Assert.Equal(0, sessions.Count); ConversationSession retried = await sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); @@ -224,7 +224,7 @@ public async Task DisposingTheOwnerDuringACloseReleasesTheWaiterWhenTheCloseIsDo await closing; _ = await turn; - _ = await Assert.ThrowsAsync(() => waiter.AsTask()); + _ = await Assert.ThrowsAsync(waiter.AsTask); } } } diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs index 56324689..315102dd 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs @@ -37,7 +37,7 @@ public async Task HeartbeatKeepsStampingAcrossTwoLongTurnsInTurnThenTheSessionEx FakeTimeProvider clock = new(DateTimeOffset.UtcNow); TaskCompletionSource[] gates = [new(), new()]; TaskCompletionSource[] entered = [new(), new()]; - CapturingChatClient reply = new(async i => { entered[i].TrySetResult(); await gates[i].Task; }, "a", "b"); + CapturingChatClient reply = new(async i => { _ = entered[i].TrySetResult(); await gates[i].Task; }, "a", "b"); using InMemoryConversationSessions sessions = new( SingleEntrySessionFactories.Of(Factory(reply: reply, workspaceRoot: _root, timeProvider: clock)), IdleTimeout, clock); ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "c1", null, Token); diff --git a/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs b/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs index a75a904a..fd2ed723 100644 --- a/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs @@ -144,9 +144,9 @@ public void NestedSymlinksInsideASweptFolderAreNotFollowed() string stale = Directory.CreateDirectory(Path.Combine(_root, "stale")).FullName; File.WriteAllText(ConversationWorkspace.MarkerPathFor(stale), string.Empty); string nested = Directory.CreateDirectory(Path.Combine(stale, "a", "b")).FullName; - Directory.CreateSymbolicLink(Path.Combine(stale, "dirlink"), outside); - Directory.CreateSymbolicLink(Path.Combine(nested, "deeplink"), outsideSub); - File.CreateSymbolicLink(Path.Combine(stale, "filelink"), precious); + _ = Directory.CreateSymbolicLink(Path.Combine(stale, "dirlink"), outside); + _ = Directory.CreateSymbolicLink(Path.Combine(nested, "deeplink"), outsideSub); + _ = File.CreateSymbolicLink(Path.Combine(stale, "filelink"), precious); Directory.SetLastWriteTimeUtc(stale, clock.GetUtcNow().UtcDateTime - TimeSpan.FromHours(1)); using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); @@ -281,7 +281,7 @@ public void ASymbolicLinkUnderTheRootIsSkippedEvenWhenItsOwnStampIsOld() try { string link = Path.Combine(_root, "linked"); - Directory.CreateSymbolicLink(link, target); + _ = Directory.CreateSymbolicLink(link, target); File.WriteAllText(Path.Combine(target, "kept.txt"), "kept"); FakeTimeProvider clock = Clock(); diff --git a/tests/AgentCore.Application.Tests/Sessions/OwnThread.cs b/tests/AgentCore.Application.Tests/Sessions/OwnThread.cs index 432b8778..700f0d49 100644 --- a/tests/AgentCore.Application.Tests/Sessions/OwnThread.cs +++ b/tests/AgentCore.Application.Tests/Sessions/OwnThread.cs @@ -10,7 +10,9 @@ internal static class OwnThread /// The open to run. /// Cancels the start. /// The task of the open. - internal static Task Run(Func> open, CancellationToken cancellationToken) => - Task.Factory.StartNew(open, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + internal static Task Run(Func> open, CancellationToken cancellationToken) + { + return Task.Factory.StartNew(open, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + } } } diff --git a/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs b/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs index f7bf27e8..cc53bc4b 100644 --- a/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/FilePublishToolDefinitionTests.cs @@ -133,7 +133,7 @@ public async Task Publish_AFileOverTheCap_IsRefusedWithTheReason() public async Task Publish_AFileInAFolder_LinksByTheStoredNameEscaped(string path, string expected) { string full = Path.Combine(_root, "conversation-1", path); - Directory.CreateDirectory(Path.GetDirectoryName(full)!); + _ = Directory.CreateDirectory(Path.GetDirectoryName(full)!); File.WriteAllText(full, "a\n"); JsonObject result = await PublishAsync(path); diff --git a/tests/AgentCore.Application.Tests/Tools/MeetingFunction.cs b/tests/AgentCore.Application.Tests/Tools/MeetingFunction.cs index 43fc1596..cdd70f0e 100644 --- a/tests/AgentCore.Application.Tests/Tools/MeetingFunction.cs +++ b/tests/AgentCore.Application.Tests/Tools/MeetingFunction.cs @@ -12,7 +12,7 @@ internal sealed class MeetingFunction(AIFunction inner, int parties) : Delegatin { if (Interlocked.Increment(ref _arrived) >= parties) { - _met.TrySetResult(); + _ = _met.TrySetResult(); } await _met.Task.WaitAsync(cancellationToken); diff --git a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs index c57c4432..76aaedfb 100644 --- a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs @@ -215,7 +215,7 @@ public async Task CommitTurn_CutBeforeAnyWord_NamesTheUserMessageAndNoReply() await provider.DrainAsync(session); ConversationMessage only = Assert.Single(store.Rows); - Assert.Equal((only.MessageId, (string?)null), ids); + Assert.Equal((only.MessageId, null), ids); } private static async Task OpenAsync(CompiledAgent compiled, AIAgent agent, RecordingConversationStore store) diff --git a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs index 1de8b309..8fbd80d3 100644 --- a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs @@ -7,7 +7,6 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; -using AgentCore.Application.Runtime.Turn.Lifecycle; namespace AgentCore.Application.Tests.Transcript { diff --git a/tests/AgentCore.Application.Tests/Transcript/CapturingChatClient.cs b/tests/AgentCore.Application.Tests/Transcript/CapturingChatClient.cs index e0d028be..41bdef12 100644 --- a/tests/AgentCore.Application.Tests/Transcript/CapturingChatClient.cs +++ b/tests/AgentCore.Application.Tests/Transcript/CapturingChatClient.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; namespace AgentCore.Application.Tests.Transcript diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs index 96076ece..d5e7bfa3 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs @@ -130,7 +130,7 @@ await SaveAnotherSessionsTurnAsync( Exception? refused = await Record.ExceptionAsync(() => a.RunTurnAsync("q2", Ct)); - Assert.IsType(refused); + _ = Assert.IsType(refused); IReadOnlyList rows = await store.ReadForSessionAsync(a.ConversationId, Ct); Assert.Equal(4, rows.Count); } diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs index 359e70b1..ef9eded1 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs @@ -1,4 +1,3 @@ -using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs index a31e0b5d..986b462f 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs @@ -6,7 +6,6 @@ using AgentCore.Application.Ports; using AgentCore.Application.Tests.Fakes; using AgentCore.Application.Tests.Runtime; -using AgentCore.Application.Tools.Binding; using AgentCore.Application.Transcript; using AgentCore.TestSupport; using Microsoft.Extensions.AI; diff --git a/tests/AgentCore.Application.Tests/Transcript/FrontVoiceCommitTests.cs b/tests/AgentCore.Application.Tests/Transcript/FrontVoiceCommitTests.cs index 7ca894a7..4a8edc6a 100644 --- a/tests/AgentCore.Application.Tests/Transcript/FrontVoiceCommitTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/FrontVoiceCommitTests.cs @@ -21,8 +21,6 @@ public sealed class FrontVoiceCommitTests FrontVoice.Line("It's Wednesday."), ]; - private static CancellationToken Ct => TestContext.Current.CancellationToken; - [Fact] public async Task CommitTurn_WritesTheLinesSaidBeforeAheadOfTheUser_AndNamesTheUserRow() { @@ -76,12 +74,14 @@ public async Task RewriteReply_LeavesTheFrontVoiceLinesAsTheyWere() store.Live(ConversationId).Select(row => row.Content.Text)); } - private static TurnCommit EndTheCall() => - new(new ChatMessage(ChatRole.User, "Okay, uh please end the call now")) + private static TurnCommit EndTheCall() + { + return new(new ChatMessage(ChatRole.User, "Okay, uh please end the call now")) { Before = SaidBefore, Seen = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Today is Sunday. Goodbye!")), UserMessageId = "d3", }; + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallAskTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallAskTests.cs index 5c0dbd50..ed1e00f6 100644 --- a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallAskTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallAskTests.cs @@ -316,7 +316,7 @@ public async Task ACarryInALaterRoundKeepsNoCallOfAnEarlierRoundAgain() price.Release.SetResult(); Assert.Equal(CallAnswer.Answered("done."), await newer); - Assert.Single( + _ = Assert.Single( call.Session.Transcript.SelectMany(message => message.Contents).OfType(), result => result.Result?.ToString() == "{\"stock\":3}"); } @@ -350,13 +350,20 @@ public async Task ACorrectionWhileTheOlderAskRunsCarriesWhatWasSaidAheadOfBothAs } } - private static ValueTask NoTools(string name) => default; + private static ValueTask NoTools(string name) + { + return default; + } - private static List<(string Role, string? Author, string Text)> Spoken(IEnumerable messages) => - [.. messages.Where(message => message.Role != ChatRole.System).Select(message => (message.Role.Value, message.AuthorName, message.Text))]; + private static List<(string Role, string? Author, string Text)> Spoken(IEnumerable messages) + { + return [.. messages.Where(message => message.Role != ChatRole.System).Select(message => (message.Role.Value, message.AuthorName, message.Text))]; + } - private static List UserWords(IReadOnlyList request) => - [.. request.Where(message => message.Role == ChatRole.User).Select(message => message.Text)]; + private static List UserWords(IReadOnlyList request) + { + return [.. request.Where(message => message.Role == ChatRole.User).Select(message => message.Text)]; + } private static async Task<(PhoneCallHarness Harness, PhoneCall Call)> StartedAsync( IChatClient model, diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs index 3676e893..4d8443e1 100644 --- a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs @@ -30,7 +30,10 @@ public async Task TheChannelFollowsTheCallToASessionReopenedAfterAnUnload() private sealed class AcceptingChannel : IConversationChannel { - public ChannelCommandResult Send(ChannelCommand command) => ChannelCommandResult.Scheduled; + public ChannelCommandResult Send(ChannelCommand command) + { + return ChannelCommandResult.Scheduled; + } } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallEndedNoticeTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallEndedNoticeTests.cs index c3d21ac3..78f9a2ef 100644 --- a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallEndedNoticeTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallEndedNoticeTests.cs @@ -88,7 +88,7 @@ public async Task ACallThatLeavesWithoutAnEndIsClosedAndItsConversationGoesOnUne await call.CloseAsync(); await session.FlushNoticesAsync(); - Assert.Equal(("call-7", 4d, (string?)null, CallEndReason.Closed), Shape(Assert.Single(hook.Of()))); + Assert.Equal(("call-7", 4d, null, CallEndReason.Closed), Shape(Assert.Single(hook.Of()))); Assert.Empty(hook.Of()); } @@ -126,7 +126,7 @@ public async Task ATakeoverEndsTheOlderAdmissionAsReplacedAndTheNewerEndsNormall await newer.Session.FlushNoticesAsync(); Assert.Equal( - [("call-7", 5d, (string?)null, CallEndReason.Replaced), ("call-7", 12d, "close_requested", CallEndReason.Ended)], + [("call-7", 5d, null, CallEndReason.Replaced), ("call-7", 12d, "close_requested", CallEndReason.Ended)], hook.Of().Select(Shape)); } @@ -156,10 +156,14 @@ private static async Task StartedAsync(PhoneCallHarness harness, stri return call; } - private static (string CallId, double Seconds, string? Cause, CallEndReason Reason) Shape(CallEnded ended) => - (ended.CallId, ended.Seconds, ended.Cause, ended.Reason); + private static (string CallId, double Seconds, string? Cause, CallEndReason Reason) Shape(CallEnded ended) + { + return (ended.CallId, ended.Seconds, ended.Cause, ended.Reason); + } - private static Type[] EndsInOrder(RecordingHook hook) => - [.. hook.Notices.Where(notice => notice is ConversationEnded or CallEnded).Select(notice => notice.GetType())]; + private static Type[] EndsInOrder(RecordingHook hook) + { + return [.. hook.Notices.Where(notice => notice is ConversationEnded or CallEnded).Select(notice => notice.GetType())]; + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallFrontVoiceTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallFrontVoiceTests.cs index 01dde22b..b6eae2a1 100644 --- a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallFrontVoiceTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallFrontVoiceTests.cs @@ -19,7 +19,7 @@ public async Task AFrontVoiceCallWithNoBriefStillTellsItsTurnsTheNote() InstructionsSeenChatClient model = new(new StallOnCueChatClient()); using PhoneCallHarness harness = PhoneCallHarness.Create(model, []); - await AskAsync(harness, harness.Host with { FrontVoice = true }); + await AskAsync(harness.Host with { FrontVoice = true }); Assert.EndsWith("\n" + CallBriefHook.FrontVoiceNote, model.Instructions[^1], StringComparison.Ordinal); } @@ -30,12 +30,12 @@ public async Task ACallWhoseVendorDoesNotSpeakForItselfIsToldNoNote() InstructionsSeenChatClient model = new(new StallOnCueChatClient()); using PhoneCallHarness harness = PhoneCallHarness.Create(model, []); - await AskAsync(harness, harness.Host); + await AskAsync(harness.Host); Assert.DoesNotContain(CallBriefHook.FrontVoiceNote, model.Instructions[^1], StringComparison.Ordinal); } - private static async Task AskAsync(PhoneCallHarness harness, PhoneCallHost host) + private static async Task AskAsync(PhoneCallHost host) { PhoneCall call = (await PhoneCall.AdmitAsync(host, PhoneCallHarness.Offer(), Ct)).Call!; await call.StartAsync(Ct); diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTakeoverTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTakeoverTests.cs index 8d7e6533..f652a5d6 100644 --- a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTakeoverTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTakeoverTests.cs @@ -49,7 +49,7 @@ public async Task ANewerCallWithTheSameIdTakesTheConversationOverAndTheOlderOneC // One call: one start and one end, its length counted from the older connection's start. Assert.Equal(new CallEnd("call-7", 12, "close_requested"), Assert.Single(hook.Of()).Call); - Assert.Single(hook.Of()); + _ = Assert.Single(hook.Of()); } // A call that already left its conversation (its end or close began) is not taken over: the conversation is diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTests.cs index 30450f78..c9d5d3d1 100644 --- a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallTests.cs @@ -68,7 +68,7 @@ public async Task AConversationAnotherCallHoldsRefusesASecondCallAsBusy() PhoneCallAdmission second = await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer("call-8"), Ct); - Assert.Equal(((PhoneCall?)null, (CallRefusal?)CallRefusal.Busy, true), (second.Call, second.Refusal, second.HeldByCall)); + Assert.Equal((null, (CallRefusal?)CallRefusal.Busy, true), (second.Call, second.Refusal, second.HeldByCall)); Assert.Same(first.Session, await harness.Sessions.TryGetAsync("main", "thread-1", Ct)); } diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs index e9c40d3c..13695446 100644 --- a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs @@ -1,7 +1,6 @@ using AgentCore.Application.Ports; using AgentCore.Application.Sessions.Memory; using AgentCore.AspNetCore.DependencyInjection.Startup; -using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.TestSupport; using Microsoft.Extensions.DependencyInjection; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/FillerKeyCheckTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/FillerKeyCheckTests.cs index 9bc28a9a..c040204c 100644 --- a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/FillerKeyCheckTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/FillerKeyCheckTests.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Configuration.Parsing; -using AgentCore.Application.Configuration.Schema; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.TestSupport; using Microsoft.Extensions.Logging; diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs index 85154759..afb3838d 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/EntryGateFactsTests.cs @@ -9,7 +9,6 @@ using AgentCore.AspNetCore.Voice.Ports; using AgentCore.AspNetCore.Voice.Routing; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; using Xunit; using static AgentCore.AspNetCore.Tests.Endpoints.EntryGateFixture; @@ -119,7 +118,7 @@ public async Task TheGatesHeadersCannotBeChanged() using HttpResponseMessage response = await PostAsync(host); - Assert.IsType(thrown); + _ = Assert.IsType(thrown); } [Fact(Timeout = 60_000)] diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs index bc1b1ae2..0ad9a9bf 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Text; -using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; using AgentCore.Application.Transcript; using AgentCore.AspNetCore.DependencyInjection.Startup; diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs index 25f53a30..ef893623 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs @@ -89,7 +89,7 @@ public async Task ApprovalAnswer_IsRaisedOnceAsAHumansApproval() new GatedToolCallingChatClient(), configure: options => { - options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => { }))); + _ = options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => { }))); _ = options.UseHooks(hook); }); diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesConversationInUseTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesConversationInUseTests.cs index 0359a73c..25a30578 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesConversationInUseTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesConversationInUseTests.cs @@ -5,7 +5,6 @@ using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Endpoints; using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.TestSupport; using Microsoft.Extensions.DependencyInjection; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepAbortTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepAbortTests.cs index 681543b6..87b60ac5 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepAbortTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepAbortTests.cs @@ -2,7 +2,6 @@ using System.Text.Json.Nodes; using AgentCore.Application.Transcript; using AgentCore.AspNetCore.Tests.Fakes; -using Microsoft.Extensions.AI; using Xunit; namespace AgentCore.AspNetCore.Tests.Endpoints diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesParallelApprovalTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesParallelApprovalTests.cs index 72d747b0..fe9760a7 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesParallelApprovalTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesParallelApprovalTests.cs @@ -218,7 +218,7 @@ private static Task StartAsync(string yaml, Dictionary { - options.AddToolSource(_ => new TwoGatedSource(name => ran[name]++)); + _ = options.AddToolSource(_ => new TwoGatedSource(name => ran[name]++)); if (hook is not null) { _ = options.UseHooks(hook); diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTurnConflictTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTurnConflictTests.cs index 2c671be9..15589df3 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTurnConflictTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTurnConflictTests.cs @@ -195,7 +195,7 @@ private static async Task AssertOnlyTheWinnerIsKeptAsync( } Assert.Equal( - new int?[] { 0, 1, 2 }, + [0, 1, 2], events.Where(item => item.Kind == AuditEventKind.TurnCompleted).Select(item => item.TurnIndex).Order()); // The refusal leaves its own row, which is all that is left of it when its client has gone. diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/CountingConversationStore.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/CountingConversationStore.cs index 01407b92..750f130f 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/CountingConversationStore.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/CountingConversationStore.cs @@ -1,5 +1,4 @@ using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Ports; using AgentCore.Application.Transcript; diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeSideband.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeSideband.cs index e6e06ab8..75510fec 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeSideband.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeSideband.cs @@ -74,10 +74,16 @@ public void Ack(JsonObject sent) } /// Closes the socket with no session.closed. - public void Complete() => _inbound.Writer.TryComplete(); + public void Complete() + { + _ = _inbound.Writer.TryComplete(); + } /// Breaks the socket: the next receive after the pushed events throws . - public void Fail(Exception fault) => _inbound.Writer.TryComplete(fault); + public void Fail(Exception fault) + { + _ = _inbound.Writer.TryComplete(fault); + } public Task WaitForSentAsync(Func match) { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs index 35b83230..9ddf9f65 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs @@ -1,15 +1,6 @@ using System.Net.WebSockets; using System.Text; using System.Threading.Channels; -using AgentCore.Application.Configuration.Parsing; -using AgentCore.AspNetCore.DependencyInjection; -using AgentCore.AspNetCore.Vendors.TelnyxRelay; -using AgentCore.TestSupport; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; namespace AgentCore.AspNetCore.Tests.Fakes { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/GatedConversationSessions.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedConversationSessions.cs index b7a783d5..f0bd73e0 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/GatedConversationSessions.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedConversationSessions.cs @@ -31,18 +31,36 @@ internal sealed class GatedConversationSessions(IConversationSessions inner) : I /// Gets a task that completes once the held lookup reached this store. public TaskCompletionSource TryGetHeld { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public void Arm() => Volatile.Write(ref _armed, 1); + public void Arm() + { + Volatile.Write(ref _armed, 1); + } - public void Release() => _gate.TrySetResult(); + public void Release() + { + _ = _gate.TrySetResult(); + } - public void Fault(Exception fault) => Volatile.Write(ref _fault, fault); + public void Fault(Exception fault) + { + Volatile.Write(ref _fault, fault); + } /// Parks the -th lookup from now on, counting from 1. - public void HoldTryGet(int ordinal) => Volatile.Write(ref _heldTryGet, Volatile.Read(ref _tryGets) + ordinal); + public void HoldTryGet(int ordinal) + { + Volatile.Write(ref _heldTryGet, Volatile.Read(ref _tryGets) + ordinal); + } - public void ReleaseTryGet() => _tryGetGate.TrySetResult(); + public void ReleaseTryGet() + { + _ = _tryGetGate.TrySetResult(); + } - public void FaultTryGets(Exception fault) => Volatile.Write(ref _tryGetFault, fault); + public void FaultTryGets(Exception fault) + { + Volatile.Write(ref _tryGetFault, fault); + } public async ValueTask GetOrOpenAsync( string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) @@ -77,7 +95,9 @@ public async ValueTask GetOrOpenAsync( return await inner.TryGetAsync(entry, conversationId, cancellationToken).ConfigureAwait(false); } - public ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) => - inner.CloseAsync(entry, conversationId, cancellationToken); + public ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + return inner.CloseAsync(entry, conversationId, cancellationToken); + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyRecorder.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyRecorder.cs index 72593977..7662dd42 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyRecorder.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyRecorder.cs @@ -8,7 +8,7 @@ internal sealed class LatencyRecorder private readonly Lock _gate = new(); private readonly List<(LatencyMetric Metric, double Seconds)> _readings = []; - public void Record(LatencyMetric metric, TimeSpan value, int? turnIndex) + public void Record(LatencyMetric metric, TimeSpan value, int? _) { lock (_gate) { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs index 30286e7c..22481c55 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using AgentCore.Application.Configuration.Parsing; @@ -120,7 +119,7 @@ public static async Task StartAsync( } _ = host?.FirstAttach.TrySetResult(sideband); - return (ILiveSideband)sideband; + return sideband; }, new Uri("https://api.openai.test/")); @@ -165,10 +164,15 @@ public Task PostWebhookAsync(string body, string webhookId return _client.SendAsync(request, TestContext.Current.CancellationToken); } - public static string IncomingCall(string callId) => - $$$"""{"object":"event","id":"evt_{{{callId}}}","type":"realtime.call.incoming","created_at":1790500000,"data":{"call_id":"{{{callId}}}","sip_headers":[{"name":"From","value":""},{"name":"To","value":""}]}}"""; + public static string IncomingCall(string callId) + { + return $$$"""{"object":"event","id":"evt_{{{callId}}}","type":"realtime.call.incoming","created_at":1790500000,"data":{"call_id":"{{{callId}}}","sip_headers":[{"name":"From","value":""},{"name":"To","value":""}]}}"""; + } - public Task StopAsync() => _app.StopAsync(TestContext.Current.CancellationToken); + public Task StopAsync() + { + return _app.StopAsync(TestContext.Current.CancellationToken); + } public async ValueTask DisposeAsync() { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/PhoneCallHarness.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/PhoneCallHarness.cs index ea16d254..6889fa85 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/PhoneCallHarness.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/PhoneCallHarness.cs @@ -2,7 +2,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Hooks; using AgentCore.Application.Hooks.BuiltIn; diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingAuditSink.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingAuditSink.cs index 447774fe..536ee0fe 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingAuditSink.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RecordingAuditSink.cs @@ -1,4 +1,3 @@ -using AgentCore.Application.Audit.Memory; using AgentCore.Application.Ports; using AgentCore.Domain.Audit; diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs index 09db2d50..68017ae0 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs @@ -1,4 +1,3 @@ -using System.Net.WebSockets; using AgentCore.Application.Configuration.Parsing; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Vendors.TelnyxRelay; diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoiceTurn.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoiceTurn.cs index a522bf18..1882c178 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoiceTurn.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoiceTurn.cs @@ -19,16 +19,28 @@ internal sealed class ScriptedVoiceTurn public Task Finished => _finished.Task; /// Streams text, then waits until the reader has handled it. - public Task TextAsync(string text) => PushAsync(new TextContent(text)); + public Task TextAsync(string text) + { + return PushAsync(new TextContent(text)); + } /// Streams a tool call, then waits until the reader has handled it. - public Task CallAsync(string callId, string name = "lookup") => PushAsync(new FunctionCallContent(callId, name)); + public Task CallAsync(string callId, string name = "lookup") + { + return PushAsync(new FunctionCallContent(callId, name)); + } /// Streams a tool result, then waits until the reader has handled it. - public Task ResultAsync(string callId) => PushAsync(new FunctionResultContent(callId, "done")); + public Task ResultAsync(string callId) + { + return PushAsync(new FunctionResultContent(callId, "done")); + } /// Ends the turn once every update pushed so far is read. - public void End() => _updates.Writer.TryComplete(); + public void End() + { + _ = _updates.Writer.TryComplete(); + } /// Plays the turn to the engine's reader. internal async IAsyncEnumerable StreamAsync( diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs index 1c2ff280..2c52fa3e 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs @@ -37,7 +37,9 @@ internal sealed class TelnyxRelayHost : IAsyncDisposable /// A Telnyx adapter that holds , as a host's secret resolver would give it. public static TelnyxRelayConversationAdapter KeyedAdapter() - => new(() => new MapSecretResolver().With(KnownSecrets.TelnyxRelayKeyName, RelayKey)); + { + return new(() => new MapSecretResolver().With(KnownSecrets.TelnyxRelayKeyName, RelayKey)); + } private readonly WebApplication _app; private readonly HttpClient _client; diff --git a/tests/AgentCore.AspNetCore.Tests/Hooks/HookRegistrationTests.cs b/tests/AgentCore.AspNetCore.Tests/Hooks/HookRegistrationTests.cs index 0b92ef2b..0a70ddd8 100644 --- a/tests/AgentCore.AspNetCore.Tests/Hooks/HookRegistrationTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Hooks/HookRegistrationTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Hooks; using AgentCore.Application.Hooks.Notices; using AgentCore.Application.Ports; -using AgentCore.Application.Secrets; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Tests.DependencyInjection; @@ -208,7 +207,10 @@ private sealed class TurnHoldingHook : AgentHook public override TimeSpan? NoticeTimeout => null; - public void Release() => _release.TrySetResult(); + public void Release() + { + _ = _release.TrySetResult(); + } public override async ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) { diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveEventReaderTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveEventReaderTests.cs index ce4b93c5..3a64e5ee 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveEventReaderTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveEventReaderTests.cs @@ -18,7 +18,7 @@ public sealed class LiveEventReaderTests ["""{"type":"session.input_transcript.delta","delta":"hi","start_ms":1200.5,"end_ms":1800.0}"""] = new LiveEvent.Transcript(Speaker.Caller, "hi", 1200, 1800), }; - public static TheoryData UnusableFrames => new(Expected.Keys); + public static TheoryData UnusableFrames => [.. Expected.Keys]; [Fact] public void TheReaderNamesAnUnmodelledEventAndAnError() diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs index cf782b1e..acba0d51 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs @@ -31,15 +31,26 @@ internal static class LiveSent """; // Every call opens with the session instructions and the greeting (see TheCallTellsGptLiveToGreetTheCallerFirst). - internal static IEnumerable AfterTheGreeting(RunningLiveCall running) => - running.Sideband.Sent.Where(sent => (string?)sent["type"] != "session.instructions.append" && !IsGreeting(sent)); + internal static IEnumerable AfterTheGreeting(RunningLiveCall running) + { + return running.Sideband.Sent.Where(sent => (string?)sent["type"] != "session.instructions.append" && !IsGreeting(sent)); + } // An answer's commentary names its delegation; the greeting's names none. - internal static bool IsCommentary(JsonObject sent) => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend && sent["delegation_id"] is not null; + internal static bool IsCommentary(JsonObject sent) + { + return (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend && sent["delegation_id"] is not null; + } - private static bool IsGreeting(JsonObject sent) => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend && sent["delegation_id"] is null; + private static bool IsGreeting(JsonObject sent) + { + return (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend && sent["delegation_id"] is null; + } - internal static string? TypeOf(string json) => (string?)JsonNode.Parse(json)!["type"]; + internal static string? TypeOf(string json) + { + return (string?)JsonNode.Parse(json)!["type"]; + } internal static async Task PushAndDrainAsync(RunningLiveCall running, IEnumerable events) { diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveTranscriptLedgerTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveTranscriptLedgerTests.cs index 59e50a11..9b8cc126 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveTranscriptLedgerTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveTranscriptLedgerTests.cs @@ -163,7 +163,9 @@ public void ADelegationClosesTheLineThatIsOpen() Assert.Empty(ledger.Flush()); } - private static void Say(LiveTranscriptLedger ledger, Speaker speaker, string delta, int start, int end) => + private static void Say(LiveTranscriptLedger ledger, Speaker speaker, string delta, int start, int end) + { _ = ledger.Add(new LiveEvent.Transcript(speaker, delta, start, end)); + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs index 69502a2f..b834dd11 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs @@ -176,7 +176,7 @@ public async Task TheGreetingWaitsForTheSessionToStart() await running.Sideband.WaitForDrainAsync(); Assert.False(sentBeforeTheStart); - Assert.Single(running.Sideband.Sent, sent => (string?)sent["content"] == RunningLiveCall.Greeting); + _ = Assert.Single(running.Sideband.Sent, sent => (string?)sent["content"] == RunningLiveCall.Greeting); } // A call whose conversation a newer call took is hung up, and says nothing. diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs index 4b30581c..a4a8a78f 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs @@ -44,7 +44,7 @@ public async Task AStopWhoseTokenFiresWhileAHangUpHangsReturns() TaskCompletionSource hangUp = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource hangingUp = new(TaskCreationOptions.RunContinuationsAsynchronously); using CancellationTokenSource stop = new(); - calls.Run(await StartedCallAsync(harness, new FakeSideband(), _ => { hangingUp.TrySetResult(); return hangUp.Task; }, null)); + calls.Run(await StartedCallAsync(harness, new FakeSideband(), ct => { _ = hangingUp.TrySetResult(); return hangUp.Task; }, null)); Task stopping = calls.StopAsync(stop.Token); await hangingUp.Task; @@ -52,7 +52,7 @@ public async Task AStopWhoseTokenFiresWhileAHangUpHangsReturns() Exception? stopped = await Record.ExceptionAsync(() => stopping.WaitAsync(TimeSpan.FromSeconds(10), Ct)); _ = hangUp.TrySetResult(true); - Assert.IsAssignableFrom(stopped); + _ = Assert.IsType(stopped, exactMatch: false); } // The host's stop ends a live call the way a normal end does, so the line the caller was still diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs index 772cccc2..93768f46 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveClockTests.cs @@ -59,6 +59,9 @@ public async Task AnEntryAgentWithClockOffTellsGptLiveNoTime() Assert.Same(greeting, running.Sideband.Sent[0]); } - private static bool IsGreeting(JsonObject sent) => (string?)sent["content"] == RunningLiveCall.Greeting; + private static bool IsGreeting(JsonObject sent) + { + return (string?)sent["content"] == RunningLiveCall.Greeting; + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveFrontVoiceTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveFrontVoiceTests.cs index 61b90005..69e2c154 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveFrontVoiceTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveFrontVoiceTests.cs @@ -110,10 +110,14 @@ private static async Task EndAsync(RunningLiveCall running) await running.Loop; } - private static string Heard(string type, string delta, int start, int end) => - new JsonObject { ["type"] = type, ["delta"] = delta, ["start_ms"] = start, ["end_ms"] = end }.ToJsonString(); + private static string Heard(string type, string delta, int start, int end) + { + return new JsonObject { ["type"] = type, ["delta"] = delta, ["start_ms"] = start, ["end_ms"] = end }.ToJsonString(); + } - private static List<(string Role, string? Author, string Text)> Spoken(IEnumerable messages) => - [.. messages.Where(message => message.Role != ChatRole.System).Select(message => (message.Role.Value, message.AuthorName, message.Text))]; + private static List<(string Role, string? Author, string Text)> Spoken(IEnumerable messages) + { + return [.. messages.Where(message => message.Role != ChatRole.System).Select(message => (message.Role.Value, message.AuthorName, message.Text))]; + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs index 27a6077f..7977fe84 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs @@ -113,7 +113,7 @@ public async Task ADelegationWhileATransferWaitsStartsNoTurn() await running.Loop; _ = await hook.WaitForAsync(); - Assert.Single(hook.Of()); + _ = Assert.Single(hook.Of()); } // A host that moves the call itself knows at once whether the line took it: nothing waits for the peer's close. @@ -166,8 +166,9 @@ public async Task ACallWithNoReferAnswersATransferAsNotSupportedAndStaysUp() } // Every tool of the harness's document asks for a transfer through the scope the binder fills, as a host's would. - private static Func TransferTool(ConcurrentQueue answers, string? ifFailed = null) => - tool => AIFunctionFactory.Create( + private static Func TransferTool(ConcurrentQueue answers, string? ifFailed = null) + { + return tool => AIFunctionFactory.Create( (ToolCallScope scope) => { ChannelCommandResult answer = scope.Channel.Send(new TransferCommand(Staff) { IfFailed = ifFailed }); @@ -175,6 +176,7 @@ public async Task ACallWithNoReferAnswersATransferAsNotSupportedAndStaysUp() return answer.ToString(); }, new AIFunctionFactoryOptions { Name = tool.Id, Description = tool.Description, ConfigureParameterBinding = ToolParameterBindings.For }); + } private static async Task ReferAsync(RunningLiveCall running) { @@ -185,15 +187,22 @@ private static async Task ReferAsync(RunningLiveCall running) _ = await running.Referred.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); } - private static string[] Ask(string delegationId, string words, int startMs) => - [ + private static string[] Ask(string delegationId, string words, int startMs) + { + return [ Heard(words, startMs), new JsonObject { ["type"] = OpenAiLiveEvents.DelegationCreated, ["delegation"] = new JsonObject { ["id"] = delegationId } }.ToJsonString(), ]; + } - private static string Heard(string words, int startMs) => - new JsonObject { ["type"] = OpenAiLiveEvents.InputTranscriptDelta, ["delta"] = " " + words, ["start_ms"] = startMs, ["end_ms"] = startMs + 1000 }.ToJsonString(); + private static string Heard(string words, int startMs) + { + return new JsonObject { ["type"] = OpenAiLiveEvents.InputTranscriptDelta, ["delta"] = " " + words, ["start_ms"] = startMs, ["end_ms"] = startMs + 1000 }.ToJsonString(); + } - private static string Closed() => new JsonObject { ["type"] = OpenAiLiveEvents.Closed, ["reason"] = "hangup" }.ToJsonString(); + private static string Closed() + { + return new JsonObject { ["type"] = OpenAiLiveEvents.Closed, ["reason"] = "hangup" }.ToJsonString(); + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs index 3ee62d88..bced87e2 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs @@ -68,9 +68,15 @@ public async Task AFactAHookSendsFromCallStartedReachesTheVoice() Assert.Equal(Fact, (string?)(await sideband.WaitForSentAsync(IsThinking))["content"]); } - private static bool IsGreeting(JsonObject sent) => (string?)sent["content"] == RunningLiveCall.Greeting; + private static bool IsGreeting(JsonObject sent) + { + return (string?)sent["content"] == RunningLiveCall.Greeting; + } - private static bool IsThinking(JsonObject sent) => (string?)sent["type"] == "session.thinking.append"; + private static bool IsThinking(JsonObject sent) + { + return (string?)sent["type"] == "session.thinking.append"; + } private sealed class TellsOnStart(TaskCompletionSource told) : AgentHook { diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookCleanupTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookCleanupTests.cs index b35aa869..c1ff5b31 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookCleanupTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookCleanupTests.cs @@ -14,7 +14,10 @@ public sealed class OpenAiLiveWebhookCleanupTests { private const string Thread = "thread-9"; - private static bool Hangup(RecordingLiveControl.Seen seen, string callId) => seen.Path == $"/v1/live/sessions/{callId}/hangup"; + private static bool Hangup(RecordingLiveControl.Seen seen, string callId) + { + return seen.Path == $"/v1/live/sessions/{callId}/hangup"; + } // The attach is the last step before the call runs. [Fact(Timeout = 30_000)] @@ -34,7 +37,7 @@ public async Task AnAttachThatFailsHangsUpEndsTheCallAndFreesItsConversation() Assert.Contains(host.Control.Requests, seen => Hangup(seen, "rtc_1")); Assert.Equal(("rtc_1", OpenAiLiveCall.AttachFailedCause), (ended.Call!.CallId, ended.Call.Cause)); Assert.Equal(HttpStatusCode.OK, second.StatusCode); - Assert.Single(host.Attached); + _ = Assert.Single(host.Attached); } // StartAsync opens the store after the accept landed: a store that is down must not leave GPT-Live a call with no sideband. @@ -57,7 +60,7 @@ [new CallDecider(gate => gate.Accept(Thread))], Assert.Equal(HttpStatusCode.InternalServerError, first.StatusCode); Assert.Contains(host.Control.Requests, seen => Hangup(seen, "rtc_1")); Assert.Equal(HttpStatusCode.OK, second.StatusCode); - Assert.Single(host.Attached); + _ = Assert.Single(host.Attached); } // A webhook id counts as handled only after a 2xx; OpenAI redelivers a 500 under the same id. @@ -78,7 +81,7 @@ public async Task ARequestThatEndsIn500LetsTheRedeliveryUnderTheSameWebhookIdThr Assert.Equal((HttpStatusCode.InternalServerError, HttpStatusCode.OK), (first.StatusCode, redelivered.StatusCode)); Assert.Equal("rtc_1", started.CallId); - Assert.Single(host.Attached); + _ = Assert.Single(host.Attached); } // A host token that is already spent must not skip the end of a call or leave its loop reading. diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs index ad08aeb0..1d772a74 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs @@ -121,8 +121,8 @@ public async Task ARepeatedWebhookForTheSameCallIsAcceptedOnce() HttpResponseMessage redelivered = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123"), webhookId: "wh_2"); Assert.Equal((HttpStatusCode.OK, HttpStatusCode.OK), (repeated.StatusCode, redelivered.StatusCode)); - Assert.Single(host.Control.Requests); - Assert.Single(host.Attached); + _ = Assert.Single(host.Control.Requests); + _ = Assert.Single(host.Attached); } [Fact(Timeout = 30_000)] @@ -175,7 +175,7 @@ public async Task AnAcceptThatFailsFreesTheConversationForTheNextCall(bool throw Assert.Equal( ["/v1/live/sessions/rtc_1/accept", "/v1/live/sessions/rtc_2/accept"], host.Control.Requests.Select(seen => seen.Path).Where(path => path.EndsWith("/accept", StringComparison.Ordinal))); - Assert.Single(host.Attached); + _ = Assert.Single(host.Attached); // A thrown accept is unknown, so the call is hung up; a refused one is rejected, so the caller is not left ringing. RecordingLiveControl.Seen[] first = [.. host.Control.Requests.Where(seen => seen.Path.StartsWith("/v1/live/sessions/rtc_1/", StringComparison.Ordinal) && !seen.Path.EndsWith("/accept", StringComparison.Ordinal))]; diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/StandardWebhookSignatureTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/StandardWebhookSignatureTests.cs index fa0dbed1..9752e8b3 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/StandardWebhookSignatureTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/StandardWebhookSignatureTests.cs @@ -80,7 +80,7 @@ public void AMalformedOrMissingHeaderFailsWithoutThrowing(string? id, string? ti [InlineData("whsec_!!!")] public void ASecretThatHoldsNoUsableKeyFails(string secret) { - Assert.Throws(() => StandardWebhookSignature.KeyOf(secret)); + _ = Assert.Throws(() => StandardWebhookSignature.KeyOf(secret)); } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/WebSocketSidebandTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/WebSocketSidebandTests.cs index 880c472a..a1202fbd 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/WebSocketSidebandTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/WebSocketSidebandTests.cs @@ -24,7 +24,7 @@ public async Task AttachSendsTheKeyAndReadsAFragmentedMessageWhole() _ = builder.Logging.ClearProviders(); await using WebApplication app = builder.Build(); _ = app.UseWebSockets(); - _ = app.Map("/v1/live/sessions/rtc_1/attach", async (HttpContext http) => + _ = app.Map("/v1/live/sessions/rtc_1/attach", async http => { authorization = http.Request.Headers.Authorization; using WebSocket socket = await http.WebSockets.AcceptWebSocketAsync(); diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs index 21fb13ae..3b5cdb3f 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayCallGateTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Hooks.Notices; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.AspNetCore.Vendors.TelnyxRelay; using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Xunit; @@ -93,7 +92,7 @@ public async Task AHangUpEndsTheCallWithItsIdAndNoCause() ConversationEnded ended = await hook.WaitForAsync(); Assert.Equal(ConversationEndReason.CallerHungUp, ended.Reason); - Assert.Equal(("conversation-one", (string?)null), (ended.Call!.CallId, ended.Call.Cause)); + Assert.Equal(("conversation-one", null), (ended.Call!.CallId, ended.Call.Cause)); } // A relay call's brief reaches the engine too. diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepCutTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepCutTests.cs index bf6f13c1..65a49d59 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepCutTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepCutTests.cs @@ -2,7 +2,6 @@ using System.Text.Json.Nodes; using AgentCore.Application.Transcript; using AgentCore.AspNetCore.Tests.Fakes; -using Microsoft.Extensions.AI; using Xunit; namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs index afbc9e74..d4c71b86 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs @@ -73,7 +73,7 @@ public async Task CancellingAWait_DoesNotSetTheEventForOtherWaiters() Task other = ev.WaitAsync(TestContext.Current.CancellationToken); await cts.CancelAsync(); - await Assert.ThrowsAsync(() => cancelled); + _ = await Assert.ThrowsAsync(() => cancelled); Assert.False(ev.IsSet); Assert.False(other.IsCompleted); diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs index aea9876c..0b4fe431 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs @@ -5,11 +5,6 @@ using AgentCore.AspNetCore.Voice.Ports; using AgentCore.AspNetCore.Voice.Routing; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Xunit; @@ -81,7 +76,7 @@ public void MappingTheCallRouteOnAHostWithNoAgentCoreRegistrationFailsAtStartup( _ = builder.Logging.ClearProviders(); WebApplication app = builder.Build(); - InvalidOperationException failure = Assert.Throws(() => app.MapCall()); + InvalidOperationException failure = Assert.Throws(app.MapCall); Assert.Contains("AddAgentCore", failure.Message, StringComparison.Ordinal); } @@ -106,19 +101,6 @@ private static ConversationSeamAdapters Build(string conversationKind, params IC return ConversationSeamStartup.Build(ConfigurationLoader.LoadYaml(Document(conversationKind)), options); } - /// Reads back the port the server bound, since the test asked for any free one. - /// The started application. - /// The base address to send to. - private static string Address(WebApplication app) - { - return app.Services - .GetRequiredService() - .Features - .Get()! - .Addresses - .First(); - } - /// Writes one document that names both blocks that require each other. /// The value providers.conversation.kind carries. /// The document text. diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs index 3834e720..4bfc0519 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs @@ -67,7 +67,9 @@ public async Task Close_IsIdempotent() FillerScheduler scheduler = new(_session, _handle, "x", TimeSpan.FromSeconds(10)); await scheduler.CloseAsync(); - await scheduler.CloseAsync(); + Exception? second = await Record.ExceptionAsync(scheduler.CloseAsync); + + Assert.Null(second); } // filler_scheduler.py (_FillerScheduler.__init__): a negative dwell or interval is refused. @@ -202,7 +204,7 @@ public async Task AnIntervalSet_FiresAgainAfterEachCooldown() for (int fire = 1; fire <= 3; fire++) { - DateTimeOffset dwellEnds = Start.AddMilliseconds(100 * fire - 50); + DateTimeOffset dwellEnds = Start.AddMilliseconds((100 * fire) - 50); await ArmedAsync(dwellEnds); _time.Advance(TimeSpan.FromMilliseconds(50)); await _output.WaitForLogAsync(2 * fire); @@ -281,7 +283,7 @@ public async Task ASourceThatRaises_IsLoggedAndStopsTheLoop() CapturedLine line = Assert.Single(logs.Of(FillerFaulted)); Assert.Equal(LogLevel.Error, line.Level); - Assert.IsType(line.Exception); + _ = Assert.IsType(line.Exception); Assert.Contains("does not support say()", line.Exception!.Message, StringComparison.Ordinal); } diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs index 41bacf1f..067b6701 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs @@ -254,7 +254,10 @@ public async Task ADoneCallbackRegisteredTwice_RunsOnce() { SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); int runs = 0; - Action counted = _ => Interlocked.Increment(ref runs); + void counted(SpeechHandle done) + { + _ = Interlocked.Increment(ref runs); + } TaskCompletionSource lastRan = new(TaskCreationOptions.RunContinuationsAsynchronously); handle.AddDoneCallback(counted); handle.AddDoneCallback(counted); diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs index 523c7e0e..31132e5f 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs @@ -116,7 +116,10 @@ public async Task Pausing_WaitsForEverySpeechTaskToEnd() [Fact(Timeout = 30_000)] public async Task WaitForIdle_CompletesAtOnceWithNoSpeech() { - await _scheduler.WaitForIdleAsync(TestContext.Current.CancellationToken); + Task idle = _scheduler.WaitForIdleAsync(TestContext.Current.CancellationToken); + + Assert.True(idle.IsCompletedSuccessfully); + await idle; } [Fact(Timeout = 30_000)] diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs index d501b4a3..00a7eb7c 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs @@ -136,7 +136,7 @@ public async Task CancellingWaitForIdle_DoesNotCancelTheUserTurnTask() Task idle = scheduler.WaitForIdleAsync(cts.Token); cts.Cancel(); - await Assert.ThrowsAsync(() => idle); + _ = await Assert.ThrowsAsync(() => idle); Assert.False(userTurnGate.Task.IsCanceled); _ = userTurnGate.TrySetResult(); @@ -153,7 +153,7 @@ public async Task CancellingWaitForIdle_DoesNotPoisonTheNextUserTurn() using CancellationTokenSource cts = new(); Task idle = _session.Scheduler.WaitForIdleAsync(cts.Token); cts.Cancel(); - await Assert.ThrowsAsync(() => idle); + _ = await Assert.ThrowsAsync(() => idle); Assert.False(staleTurn.Task.IsCanceled); _ = staleTurn.TrySetResult(); diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityHooksTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityHooksTests.cs index 1fb31623..fc8217d4 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityHooksTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityHooksTests.cs @@ -1,6 +1,4 @@ using AgentCore.Application.Hooks.Notices; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Session; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; @@ -19,8 +17,6 @@ namespace AgentCore.AspNetCore.Tests.Voice { public sealed class VoiceActivityHooksTests { - private static CancellationToken Ct => TestContext.Current.CancellationToken; - // With no sink injected, a reply's clocks and the session's states reach the // conversation's hooks as notices, the readings stamped with the engine turn they belong to. [Fact(Timeout = 60_000)] diff --git a/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs b/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs index 80102654..1c93f63e 100644 --- a/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs +++ b/tests/AgentCore.Hosting.Tests/AgentCoreHostRouteTests.cs @@ -2,9 +2,7 @@ using AgentCore.Application.Hooks; using AgentCore.Application.Hooks.Gates; using AgentCore.AspNetCore.DependencyInjection; -using AgentCore.AspNetCore.Endpoints; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Xunit; diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTurnConflictTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTurnConflictTests.cs index e09db414..ef01a3e1 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTurnConflictTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTurnConflictTests.cs @@ -33,7 +33,7 @@ public async Task AppendAsync_ATurnAlreadySaved_IsRefused_AndWritesNeitherRowsNo Exception? refused = await Record.ExceptionAsync(() => CommitAsync(store, "c1", 1, "second")); // Assert - Assert.IsType(refused); + _ = Assert.IsType(refused); IReadOnlyList rows = await store.ReadAllAsync("c1", Token); Assert.Equal(["opener-user", "opener-reply", "first-user", "first-reply"], rows.Select(row => row.MessageId)); ConversationRecord? record = await store.GetAsync("c1", Token); diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs index 99b8d539..9efca179 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs @@ -1,11 +1,9 @@ -using AgentCore.Application.Conversation; using AgentCore.Application.Transcript; using AgentCore.Domain.Audit; using AgentCore.Domain.Sources; using AgentCore.Infrastructure.Audit.Postgres; using AgentCore.Infrastructure.Conversation.Postgres; using AgentCore.Infrastructure.Tests.Database.Postgres; -using AgentCore.TestSupport; using Microsoft.Extensions.AI; using Xunit; diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs index d70ac0e9..fc36bab3 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs @@ -3,7 +3,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Conversation; using AgentCore.Application.Hooks; using AgentCore.Domain.Audit; using AgentCore.Infrastructure.Audit.Postgres; diff --git a/tests/AgentCore.Infrastructure.Tests/Knowledge/VectorData/Qdrant/AmbiguityIntegrationTests.cs b/tests/AgentCore.Infrastructure.Tests/Knowledge/VectorData/Qdrant/AmbiguityIntegrationTests.cs index c9a437d4..a065ced7 100644 --- a/tests/AgentCore.Infrastructure.Tests/Knowledge/VectorData/Qdrant/AmbiguityIntegrationTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Knowledge/VectorData/Qdrant/AmbiguityIntegrationTests.cs @@ -13,7 +13,6 @@ using Qdrant.Client; using Qdrant.Client.Grpc; using Xunit; -using AgentCore.Application.Runtime.Clarification; using AgentCore.Application.Runtime.Session; namespace AgentCore.Infrastructure.Tests.Knowledge.VectorData.Qdrant diff --git a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs index 091c2c47..0b6eba87 100644 --- a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs @@ -177,7 +177,7 @@ public async Task ARequestOutsideAnyConversation_Throws() StubHttpMessageHandler endpoint = StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer); IChatClient client = await BuildClientAsync(new StubHandlerFactory(endpoint)); - await Assert.ThrowsAsync( + _ = await Assert.ThrowsAsync( async () => await client.GetResponseAsync("hi", options: null, Token)); Assert.Empty(endpoint.Requests); diff --git a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs index 7ac43c58..30b3e1d6 100644 --- a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs @@ -3,7 +3,6 @@ using System.Net; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Secrets; using AgentCore.Infrastructure.Llm.OpenCodeGo; using AgentCore.Infrastructure.Tests.Fakes; using AgentCore.Infrastructure.Tests.Tools; @@ -156,7 +155,7 @@ public async Task AnEntryWithAnEffort_PutsReasoningEffortAndStoreFalseOnTheReque }, Token); - Assert.Single(endpoint.Requests); + _ = Assert.Single(endpoint.Requests); Assert.Contains("\"reasoning\":{\"effort\":\"high\"}", body, StringComparison.Ordinal); Assert.Contains("\"store\":false", body, StringComparison.Ordinal); } diff --git a/tests/AgentCore.TestSupport/GatedTool.cs b/tests/AgentCore.TestSupport/GatedTool.cs index 16de1ea3..d1d02cbc 100644 --- a/tests/AgentCore.TestSupport/GatedTool.cs +++ b/tests/AgentCore.TestSupport/GatedTool.cs @@ -25,7 +25,10 @@ public sealed class GatedTool public int Finished => Volatile.Read(ref _finished); - public AIFunction Create(string name, string description) => AIFunctionFactory.Create(RunAsync, name, description); + public AIFunction Create(string name, string description) + { + return AIFunctionFactory.Create(RunAsync, name, description); + } private async Task RunAsync(CancellationToken cancellationToken) { diff --git a/tests/AgentCore.TestSupport/MapModelCatalogPort.cs b/tests/AgentCore.TestSupport/MapModelCatalogPort.cs index 4f7626ee..5d9c69cf 100644 --- a/tests/AgentCore.TestSupport/MapModelCatalogPort.cs +++ b/tests/AgentCore.TestSupport/MapModelCatalogPort.cs @@ -9,7 +9,7 @@ namespace AgentCore.TestSupport /// public sealed class MapModelCatalogPort : IModelCatalogPort { - private readonly Dictionary<(string Provider, string Model), ModelCatalogEntry> _values = new(); + private readonly Dictionary<(string Provider, string Model), ModelCatalogEntry> _values = []; /// Adds one provider and model pair's entry, and returns this catalog. public MapModelCatalogPort With(string provider, string model, int contextWindow) diff --git a/tests/AgentCore.TestSupport/RecordingHook.cs b/tests/AgentCore.TestSupport/RecordingHook.cs index e63b5306..86af2b7b 100644 --- a/tests/AgentCore.TestSupport/RecordingHook.cs +++ b/tests/AgentCore.TestSupport/RecordingHook.cs @@ -37,7 +37,10 @@ public async Task WaitForAsync(Func? match = null) where T : HookNotice { TaskCompletionSource seen = new(TaskCreationOptions.RunContinuationsAsynchronously); - bool Matches(HookNotice notice) => notice is T typed && (match?.Invoke(typed) ?? true); + bool Matches(HookNotice notice) + { + return notice is T typed && (match?.Invoke(typed) ?? true); + } lock (_gate) { @@ -52,35 +55,150 @@ public async Task WaitForAsync(Func? match = null) return (T)await seen.Task.ConfigureAwait(false); } - public override ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnHostStoppingAsync(HostStopping notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnRetentionSweptAsync(RetentionSwept notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnCallStartedAsync(CallStarted notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnLineSpokenAsync(LineSpoken notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnCallEndedAsync(CallEnded notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnKnowledgeSearchedAsync(KnowledgeSearched notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnSkillLoadedAsync(SkillLoaded notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnCompactedAsync(Compacted notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnReplyUpdatedAsync(ReplyUpdated notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnModelCalledAsync(ModelCalled notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnApprovalChangedAsync(ApprovalChanged notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnFilePublishedAsync(FilePublished notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnSubagentStartedAsync(SubagentStarted notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnSubagentEndedAsync(SubagentEnded notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnVoiceStateChangedAsync(VoiceStateChanged notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnTurnLatencyAsync(TurnLatency notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnStageChangedAsync(StageChanged notice, CancellationToken cancellationToken) => Record(notice); - public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) => Record(notice); + public override ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnHostStoppingAsync(HostStopping notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnRetentionSweptAsync(RetentionSwept notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnCallStartedAsync(CallStarted notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnLineSpokenAsync(LineSpoken notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnCallEndedAsync(CallEnded notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnKnowledgeSearchedAsync(KnowledgeSearched notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnSkillLoadedAsync(SkillLoaded notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnCompactedAsync(Compacted notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnReplyUpdatedAsync(ReplyUpdated notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnModelCalledAsync(ModelCalled notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnApprovalChangedAsync(ApprovalChanged notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnFilePublishedAsync(FilePublished notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnSubagentStartedAsync(SubagentStarted notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnSubagentEndedAsync(SubagentEnded notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnVoiceStateChangedAsync(VoiceStateChanged notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnTurnLatencyAsync(TurnLatency notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnStageChangedAsync(StageChanged notice, CancellationToken cancellationToken) + { + return Record(notice); + } + + public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) + { + return Record(notice); + } private ValueTask Record(HookNotice notice) { From b65c3655a931a05286cad7c2256e068b8afb1f89 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Wed, 7 Oct 2026 09:55:07 -0600 Subject: [PATCH 3/3] feat: enhance handling of interruptions and text forwarding in speech replies --- .../Connection/TelnyxRelayConnection.cs | 3 +- .../Voice/Speech/Replies/EngineReplyStream.cs | 3 + .../Voice/Speech/Replies/PipelineReply.cs | 55 +++++++++++++-- .../Voice/Speech/Replies/ReplyHearing.cs | 53 +++++++++++++-- .../Voice/Speech/Replies/TextForwarding.cs | 22 ++++-- .../Voice/Speech/Replies/TextPlayback.cs | 4 +- .../Voice/Speech/SpeechScheduler.cs | 3 +- .../Voice/Transport/JsonWebSocketPump.cs | 24 ++++++- .../Fakes/FakeRelayClient.cs | 12 ++++ .../Fakes/RelayConnectionHarness.cs | 16 ++++- .../TelnyxRelayBargeInRecoveryTests.cs | 7 +- .../TelnyxRelay/TelnyxRelayFrameCostTests.cs | 28 +++++--- .../TelnyxRelay/TelnyxRelayLineSpokenTests.cs | 22 ++++++ .../Voice/ReplyHearingTests.cs | 68 +++++++++++++++++++ .../Voice/SpeechSchedulerTests.cs | 4 +- .../Voice/TextForwardingTests.cs | 24 +++++++ 16 files changed, 311 insertions(+), 37 deletions(-) create mode 100644 tests/AgentCore.AspNetCore.Tests/Voice/ReplyHearingTests.cs diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs index e87e124b..1e4fc5b9 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/Connection/TelnyxRelayConnection.cs @@ -85,7 +85,8 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt frameType => TelnyxRelayLog.FrameBodyRefused(_logger, frameType, ConversationIdForLog), () => TelnyxRelayLog.IdleTimeoutReached(_logger, ConversationIdForLog)), timeProvider, - connectionToken); + connectionToken, + http.RequestAborted); _sender = new JsonWebSocketSender(socket, _options.CloseTimeout, connectionToken); diff --git a/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs index d24f5ed2..3463c980 100644 --- a/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/EngineReplyStream.cs @@ -152,6 +152,9 @@ public bool TryPeek(out ReplyEvent next) return _events.Reader.TryPeek(out next); } + /// Gets whether the turn has ended and every event was read. + public bool Ended => _events.Reader.Completion.IsCompleted; + /// Yields the current step's text, and stops before the step's first tool call or at the turn's end. /// Stops the enumeration. public async IAsyncEnumerable ReadStepTextAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) diff --git a/src/AgentCore.AspNetCore/Voice/Speech/Replies/PipelineReply.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/PipelineReply.cs index 331189d2..508f40f4 100644 --- a/src/AgentCore.AspNetCore/Voice/Speech/Replies/PipelineReply.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/PipelineReply.cs @@ -69,7 +69,16 @@ public async Task RunAsync() return; } - await ForwardStepAsync(cancellationToken).ConfigureAwait(false); + TextPlayback? played = await ForwardStepAsync(cancellationToken).ConfigureAwait(false); + + // Every word is out and the engine turn is over, so an interruption from here on cuts nothing: the + // reply ends as a whole one does, and the transport's report settles its line. + bool spokeWhole = played == TextPlayback.Full && stream.Ended; + if (spokeWhole) + { + Hearing.MarkSpokenWhole(); + } + bool hasTools = !speechHandle.IsInterrupted && stream.TryPeek(out ReplyEvent next) && next.Kind == ReplyEventKind.ToolCall; @@ -83,16 +92,27 @@ public async Task RunAsync() session.SetAgentState(AgentState.Listening); } + if (speechHandle.IsInterrupted && !spokeWhole) + { + bool started = await SettleCutAsync(cancellationToken).ConfigureAwait(false); + + speechHandle.MarkGenerationDone(); + + await WaitForCutTurnAsync(started, cancellationToken).ConfigureAwait(false); + + return; + } + speechHandle.MarkGenerationDone(); - if (speechHandle.IsInterrupted) + if (speechHandle.IsInterrupted && !spokeWhole) { await EndInterruptedAsync(cancellationToken).ConfigureAwait(false); return; } bool roundDone = hasTools && await WaitForToolsAsync(cancellationToken).ConfigureAwait(false); - if (speechHandle.IsInterrupted) + if (speechHandle.IsInterrupted && !spokeWhole) { await EndInterruptedAsync(cancellationToken).ConfigureAwait(false); return; @@ -109,7 +129,8 @@ public async Task RunAsync() } } - private async Task ForwardStepAsync(CancellationToken cancellationToken) + /// How the step's text reached the output, or when no text was forwarded. + private async Task ForwardStepAsync(CancellationToken cancellationToken) { Task ready = stream.WaitToReadAsync(cancellationToken).AsTask(); await speechHandle.WaitIfNotInterruptedAsync([ready]).ConfigureAwait(false); @@ -120,7 +141,7 @@ private async Task ForwardStepAsync(CancellationToken cancellationToken) || !stream.TryPeek(out ReplyEvent first) || first.Kind != ReplyEventKind.Text) { - return; + return null; } TextForwardingResult forwarded = await TextForwarding.ForwardAsync( @@ -135,13 +156,18 @@ private async Task ForwardStepAsync(CancellationToken cancellationToken) { metrics.MarkSpeechEnded(); } + + return forwarded.Playback; } private void OnFirstText() { Hearing.MarkFirstText(); + onFirstText(this); + session.SetAgentState(AgentState.Speaking); + metrics.MarkFirstSpeech(); } @@ -166,7 +192,9 @@ void OnToolEvent(ReplyEvent toolEvent) } session.Scheduler.AddBackgroundSpeech(speechHandle); + using CancellationTokenSource roundCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task round = stream.WaitForRoundAsync(OnToolEvent, roundCancellation.Token); try { @@ -177,8 +205,11 @@ void OnToolEvent(ReplyEvent toolEvent) { // The round reader stops before the scopes close, so it opens none behind this cleanup. await TaskTeardown.CancelAndWaitAsync(roundCancellation, [round]).ConfigureAwait(false); + session.Scheduler.RemoveBackgroundSpeech(speechHandle); + closing.AddRange(scopes.Values.Select(scope => scope.DisposeAsync().AsTask())); + await Task.WhenAll(closing).ConfigureAwait(false); } } @@ -201,10 +232,24 @@ private void EndReply() } private async Task EndInterruptedAsync(CancellationToken cancellationToken) + { + bool started = await SettleCutAsync(cancellationToken).ConfigureAwait(false); + + await WaitForCutTurnAsync(started, cancellationToken).ConfigureAwait(false); + } + + /// when the reply's engine turn has started, so its end is worth waiting for. + private async Task SettleCutAsync(CancellationToken cancellationToken) { bool started = Hearing.CutInterrupted(); + await Hearing.RecutToReportAsync(cancellationToken).ConfigureAwait(false); + return started; + } + + private async Task WaitForCutTurnAsync(bool started, CancellationToken cancellationToken) + { // A turn still waiting on the one before it takes the cut when it starts, and nothing here waits for it. if (!started) { diff --git a/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyHearing.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyHearing.cs index 791cca9b..9d81aaa4 100644 --- a/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyHearing.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/ReplyHearing.cs @@ -48,6 +48,8 @@ internal sealed class ReplyHearing( private bool _cutSettled; + private bool _spokeWhole; + private int _reportWaitStarted; /// Gets whether the reply has handed any text to the output yet. @@ -83,12 +85,42 @@ public bool AwaitsBarge } } - /// Marks the interruption about to land as a final prompt's, which the transport's report may follow. + /// + /// Marks the interruption about to land as a final prompt's, which the transport's report may follow. A reply + /// spoken whole loses nothing to it, so it waits for no report on that account. + /// public void ExpectBarge() { lock (_gate) { - _expectsBarge = true; + _expectsBarge = !_spokeWhole; + } + } + + /// + /// Marks every word of the reply forwarded and its engine turn over. An interruption from here on cuts nothing, + /// so the transport's report settles the line as it does for a reply already done. + /// + public void MarkSpokenWhole() + { + (string HeardText, TimeSpan PlayedDuration)? kept = null; + lock (_gate) + { + _spokeWhole = true; + + // A final prompt's interruption landed after the last word and kept a report for a recut that will not run. + if (_expectsBarge && _cutText is null) + { + kept = _barge; + _barge = null; + } + + _expectsBarge = false; + } + + if (kept is { } heard) + { + _ = CutHeard(heard.HeardText, heard.PlayedDuration); } } @@ -165,13 +197,20 @@ public bool CutHeard(string heardText, TimeSpan playedDuration) /// /// Marks the reply's speech done: its text is with the transport, which may still be playing it. An interrupted - /// reply settled its cut before its speech was done, so its barge window closes too. + /// reply settled its cut before its speech was done, so its barge window closes too, unless it was spoken whole + /// and nothing cut it. /// /// Whether the speech was interrupted. public void MarkSpoken(bool interrupted) { + bool settled; + lock (_gate) + { + settled = interrupted && (!_spokeWhole || _cutText is not null); + } + _ = _spoken.TrySetResult(); - if (interrupted) + if (settled) { CloseBargeWindow(); } @@ -215,6 +254,7 @@ public async Task CloseBargeWindowAfterReportWaitAsync() public async Task HeardWhenSettledAsync() { await Task.WhenAll(_spoken.Task, _bargeWindowClosed.Task).ConfigureAwait(false); + return HeardText; } @@ -226,10 +266,13 @@ public bool CutInterrupted() lock (_gate) { bool forwardedText = _forwardedText; + _cutSettled = !forwardedText || _barge is not null; + cut = !forwardedText ? NothingHeard : _barge is { } heard ? Heard(heard.HeardText, heard.PlayedDuration) : new TurnCut(_forwarded, Played: null); + _cutText = cut.ShownText; } @@ -254,7 +297,9 @@ public async Task RecutToReportAsync(CancellationToken cancellationToken) } _cutSettled = true; + cut = Heard(heard.HeardText, heard.PlayedDuration); + _cutText = cut.ShownText; } diff --git a/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwarding.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwarding.cs index 76b1a50e..0039488f 100644 --- a/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwarding.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextForwarding.cs @@ -47,7 +47,7 @@ public static async Task ForwardAsync( // Before the task starts, so a barge-in's StopAsync also cuts off a fragment that already passed the // IsInterrupted check below and is still on its way to the output. output.BeginReply(); - Task forwardTask = ForwardTextAsync(speechHandle, output, source, text, onFirstText, forwardCancellation.Token, cancellationToken); + Task forwardTask = ForwardTextAsync(speechHandle, output, source, text, onFirstText, forwardCancellation.Token, cancellationToken); await speechHandle.WaitIfNotInterruptedAsync([forwardTask]).ConfigureAwait(false); if (speechHandle.IsInterrupted) @@ -55,14 +55,22 @@ public static async Task ForwardAsync( await TaskTeardown.CancelAndWaitAsync(forwardCancellation, [forwardTask]).ConfigureAwait(false); } + // Whole once the source ran out, even when an interruption landed after its last fragment: it cut nothing. + bool whole = !speechHandle.IsInterrupted || (forwardTask.IsCompletedSuccessfully && forwardTask.Result); string forwarded = text.ToString(); - TextPlayback playback = forwarded.Length == 0 - ? TextPlayback.Skipped - : speechHandle.IsInterrupted ? TextPlayback.Partial : TextPlayback.Full; + + TextPlayback playback = (forwarded.Length, whole) switch + { + (0, _) => TextPlayback.Skipped, + (_, true) => TextPlayback.Full, + _ => TextPlayback.Partial, + }; + return new TextForwardingResult(forwarded, playback); } - private static async Task ForwardTextAsync( + /// once the source ran out; when an interruption stopped it first. + private static async Task ForwardTextAsync( SpeechHandle speechHandle, IConversationOutputPort output, IAsyncEnumerable source, @@ -80,7 +88,7 @@ private static async Task ForwardTextAsync( // another thread, and a delta read before the cancellation arrives must not be spoken. if (speechHandle.IsInterrupted) { - break; + return false; } _ = text.Append(delta); @@ -98,6 +106,8 @@ private static async Task ForwardTextAsync( await output.SpeakAsync(delta, forwardCancellationToken).ConfigureAwait(false); } } + + return true; } finally { diff --git a/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextPlayback.cs b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextPlayback.cs index aac2d74e..d3f61ff4 100644 --- a/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextPlayback.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/Replies/TextPlayback.cs @@ -12,10 +12,10 @@ internal enum TextPlayback /// Nothing was ever forwarded. Skipped, - /// Some text reached the output before the speech was interrupted. + /// Some text reached the output before the speech was interrupted, and the rest never did. Partial, - /// Every fragment reached the output and the speech was not interrupted. + /// Every fragment reached the output; an interruption, if any, came after the last one. Full, } } diff --git a/src/AgentCore.AspNetCore/Voice/Speech/SpeechScheduler.cs b/src/AgentCore.AspNetCore/Voice/Speech/SpeechScheduler.cs index 9310ae31..35b080de 100644 --- a/src/AgentCore.AspNetCore/Voice/Speech/SpeechScheduler.cs +++ b/src/AgentCore.AspNetCore/Voice/Speech/SpeechScheduler.cs @@ -43,7 +43,7 @@ public SpeechHandle? CurrentSpeech { lock (_lock) { - return _currentSpeech; + return _currentSpeech is { IsDone: false } current ? current : null; } } } @@ -128,6 +128,7 @@ public Task CreateSpeechTask(Func body, SpeechHandle? speechHandle = null) CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); + return task; } diff --git a/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPump.cs b/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPump.cs index 3b04e5a9..e72b1863 100644 --- a/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPump.cs +++ b/src/AgentCore.AspNetCore/Voice/Transport/JsonWebSocketPump.cs @@ -14,11 +14,16 @@ namespace AgentCore.AspNetCore.Voice.Transport /// Cancelled once, for any reason the socket is going away: the request aborted, the host is /// stopping, or one of the connection's own tasks ended. /// + /// + /// Cancelled once the peer's connection dropped. The pump then reads on until the socket reports the drop, so the + /// frames the peer sent before it went still reach the conversation. + /// internal sealed class JsonWebSocketPump( WebSocket socket, JsonWebSocketPumpOptions options, TimeProvider timeProvider, - CancellationToken connectionToken) + CancellationToken connectionToken, + CancellationToken peerGoneToken) { private bool _loggedUnknownFrame; private bool _loggedRefusedFrameBody; @@ -43,7 +48,7 @@ public async Task ReadLoopAsync(FrameParser parse, Func dispatch) try { - while (!connectionToken.IsCancellationRequested) + while (!connectionToken.IsCancellationRequested || peerGoneToken.IsCancellationRequested) { message.ResetWrittenCount(); @@ -69,7 +74,8 @@ public async Task ReadLoopAsync(FrameParser parse, Func dispatch) Task idling = Task.Delay(options.IdleTimeout, timeProvider, idleCancel.Token); Task receiving = socket.ReceiveAsync(rented.AsMemory(), CancellationToken.None).AsTask(); - if (await Task.WhenAny(receiving, idling).ConfigureAwait(false) != receiving) + if (await Task.WhenAny(receiving, idling).ConfigureAwait(false) != receiving + && !(peerGoneToken.IsCancellationRequested && await ReceivedBeforeCloseTimeoutAsync(receiving).ConfigureAwait(false))) { // idling won: either IdleTimeout actually elapsed, or the connection token // itself fired and cancelled this Task.Delay along with it — host stopping, or @@ -178,6 +184,18 @@ private async Task AssembleMessageAsync( } } + /// + /// Waits for the receive the peer's drop cancelled the idle race of. The peer can send its last words and drop + /// the line at once, and the drop can reach the request before the receive hands over those words, which are + /// already on the socket; once they are read, the next receive reports the drop. + /// + /// The receive still in flight. + /// when the receive completed inside the close timeout. + private async Task ReceivedBeforeCloseTimeoutAsync(Task receiving) + { + return await Task.WhenAny(receiving, Task.Delay(options.CloseTimeout, CancellationToken.None)).ConfigureAwait(false) == receiving; + } + /// Logs, or closes the socket over, a message the parser produced no frame for. /// An outcome whose is . private void RefuseFrame(FrameOutcome outcome) diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeRelayClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeRelayClient.cs index 09556614..df541d7e 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeRelayClient.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeRelayClient.cs @@ -176,6 +176,18 @@ public async Task> ReadFramesForAsync(TimeSpan window) } } + /// Hangs up as the vendor does: sends its own close frame, then waits for the host's. + /// + /// aborts the socket, and an abort can reset the connection before the host + /// has read what was sent just before it. + /// + /// A task that completes once the host closed too. + public async Task HangUpAsync() + { + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, TestContext.Current.CancellationToken); + _ = await ReadCloseAsync(); + } + /// Drops the socket with no close frame, exactly like a conversation that died. public void Abort() { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs index 68017ae0..6c3c16df 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs @@ -19,15 +19,18 @@ internal sealed class RelayConnectionHarness : IAsyncDisposable { private readonly ServiceProvider _services; private readonly TestHostLifetime _lifetime; + private readonly CancellationTokenSource _requestAborted; private RelayConnectionHarness( ServiceProvider services, TestHostLifetime lifetime, + CancellationTokenSource requestAborted, FakeWebSocket socket, Task connection) { _services = services; _lifetime = lifetime; + _requestAborted = requestAborted; Socket = socket; Connection = connection; } @@ -93,7 +96,8 @@ public static async Task StartAsync( await service.StartingAsync(CancellationToken.None); } - DefaultHttpContext http = new() { RequestServices = provider }; + CancellationTokenSource requestAborted = new(); + DefaultHttpContext http = new() { RequestServices = provider, RequestAborted = requestAborted.Token }; http.Request.RouteValues[ConversationEndpointRouteBuilderExtensions.EntryRouteParameter] = entry; TelnyxRelayOptions options = new(); @@ -102,8 +106,15 @@ public static async Task StartAsync( FakeWebSocket socket = new(); Task connection = TelnyxRelayConnection.RunAsync(http, socket, options); - return new RelayConnectionHarness(provider, lifetime, socket, connection); + return new RelayConnectionHarness(provider, lifetime, requestAborted, socket, connection); } + + /// Aborts the request, as Kestrel does once the peer's connection drops. + public void DropPeer() + { + _requestAborted.Cancel(); + } + /// Stops the host, which is what the EndpointUnavailable close status reports. public void StopApplication() { @@ -129,6 +140,7 @@ public async ValueTask DisposeAsync() } Socket.Dispose(); + _requestAborted.Dispose(); _lifetime.Dispose(); await _services.DisposeAsync().ConfigureAwait(false); } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs index a204b525..3a254416 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.Domain; using Microsoft.Extensions.Logging; @@ -36,11 +37,13 @@ public async Task ASecondFinalPromptDuringATurn_InterruptsTheFirstAndTheSecondAn try { - await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); + // The first piece must reach the relay, not just leave the model: a prompt that lands before it + // cuts a reply that never spoke, and that reply sends no closing frame to wait for. + JsonNode opening = await relay.ReadFrameAsync().WaitAsync(bounded.Token); await relay.SendAsync(RelayFrames.Prompt("two", last: true)); // Turn one closes its reply as it is interrupted, with only what reached the relay. - first = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); + first = [opening["token"]!.GetValue(), .. await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token)]; } catch (OperationCanceledException) when (deadline.IsCancellationRequested) { diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFrameCostTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFrameCostTests.cs index 020eaa01..903304e4 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFrameCostTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFrameCostTests.cs @@ -84,13 +84,19 @@ private static async Task MeasureAsync(int frames, string conversatio Lock gate = new(); int measurements = 0; int instruments = 0; + TaskCompletionSource conversationAudited = new(TaskCreationOptions.RunContinuationsAsynchronously); - void RecordMeasurement() + void RecordMeasurement(Instrument instrument, ReadOnlySpan> tags) { lock (gate) { measurements++; } + + if (instrument.Name == "agentcore.audit.events" && tags.ToArray().Any(tag => Equals(tag.Value, "conversation.started"))) + { + _ = conversationAudited.TrySetResult(); + } } using MeterListener listener = new(); @@ -115,13 +121,13 @@ void RecordMeasurement() // Every numeric type an instrument may carry, not only the double of the turn histogram and // the long of the three counters. A per-frame instrument somebody adds later is counted // here whatever its type, rather than slipping past an incomplete set of callbacks. - listener.SetMeasurementEventCallback((_, _, _, _) => RecordMeasurement()); - listener.SetMeasurementEventCallback((_, _, _, _) => RecordMeasurement()); - listener.SetMeasurementEventCallback((_, _, _, _) => RecordMeasurement()); - listener.SetMeasurementEventCallback((_, _, _, _) => RecordMeasurement()); - listener.SetMeasurementEventCallback((_, _, _, _) => RecordMeasurement()); - listener.SetMeasurementEventCallback((_, _, _, _) => RecordMeasurement()); - listener.SetMeasurementEventCallback((_, _, _, _) => RecordMeasurement()); + listener.SetMeasurementEventCallback((instrument, _, tags, _) => RecordMeasurement(instrument, tags)); + listener.SetMeasurementEventCallback((instrument, _, tags, _) => RecordMeasurement(instrument, tags)); + listener.SetMeasurementEventCallback((instrument, _, tags, _) => RecordMeasurement(instrument, tags)); + listener.SetMeasurementEventCallback((instrument, _, tags, _) => RecordMeasurement(instrument, tags)); + listener.SetMeasurementEventCallback((instrument, _, tags, _) => RecordMeasurement(instrument, tags)); + listener.SetMeasurementEventCallback((instrument, _, tags, _) => RecordMeasurement(instrument, tags)); + listener.SetMeasurementEventCallback((instrument, _, tags, _) => RecordMeasurement(instrument, tags)); listener.Start(); RecordCountingLoggerProvider records = new(); @@ -166,10 +172,14 @@ void RecordMeasurement() try { await sentinel.Observed.WaitAsync(bounded.Token); + + // The conversation's own audit event is written off the read loop, so it can land before or after + // the sentinel. It is waited for too, so both runs count it. + await conversationAudited.Task.WaitAsync(bounded.Token); } catch (OperationCanceledException) when (deadline.IsCancellationRequested) { - Assert.Fail($"the connection never read past the {frames} interim prompts within twenty seconds."); + Assert.Fail($"the connection never read past the {frames} interim prompts, or never audited its start, within twenty seconds."); } // Counted here, with the conversation still up, rather than after the socket and the host are diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayLineSpokenTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayLineSpokenTests.cs index 02e88a27..1fd2e039 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayLineSpokenTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayLineSpokenTests.cs @@ -1,3 +1,4 @@ +using System.Net.WebSockets; using AgentCore.Application.Hooks.Notices; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Tests.Fakes; @@ -169,6 +170,7 @@ public async Task TheReplyOfTheTurnThatEndedTheConversationAndTheCallersWordsAft await ReplySentAsync(hook); ConversationEnded ended = await hook.WaitForAsync(); await relay.SendAsync(RelayFrames.Prompt("okay bye", last: true)); + await relay.HangUpAsync(); await relay.DisposeAsync(); _ = await hook.WaitForAsync().WaitAsync(Bound, TestContext.Current.CancellationToken); @@ -179,6 +181,26 @@ public async Task TheReplyOfTheTurnThatEndedTheConversationAndTheCallersWordsAft Assert.Equal(ConversationEndReason.AgentCompleted, ended.Reason); } + // A caller can say their last words and drop the line at once. The drop can reach the request before the + // read loop takes those words off the socket, and they are still a line. + [Fact(Timeout = 30_000)] + public async Task WordsTheCallerSentBeforeTheLineDroppedAreStillALine() + { + RecordingHook hook = new(); + using FragmentingChatClient reply = new("hello"); + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, reply, configure: options => _ = options.UseHooks(hook)); + + harness.Socket.Queue(RelayFrames.Setup()); + _ = await hook.WaitForAsync().WaitAsync(Bound, TestContext.Current.CancellationToken); + harness.DropPeer(); + harness.Socket.Queue(RelayFrames.Prompt("bye", last: true)); + harness.Socket.FailReceive(new WebSocketException(WebSocketError.ConnectionClosedPrematurely)); + + LineSpoken line = await hook.WaitForAsync(line => line.Text == "bye").WaitAsync(Bound, TestContext.Current.CancellationToken); + Assert.Equal(Speaker.Caller, line.Speaker); + } + // The reply's end latency is read as its last step ends, after every word went to the relay. private static async Task ReplySentAsync(RecordingHook hook) { diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ReplyHearingTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ReplyHearingTests.cs new file mode 100644 index 00000000..22b76b09 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ReplyHearingTests.cs @@ -0,0 +1,68 @@ +using AgentCore.Application.Runtime.Cut; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice.Options; +using AgentCore.AspNetCore.Voice.Speech; +using AgentCore.AspNetCore.Voice.Speech.Replies; +using AgentCore.AspNetCore.Voice.Turns; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// What an interruption does to a reply whose every word was already out when it landed. + public sealed class ReplyHearingTests + { + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly ScriptedVoicePort _port = new(); + + // The interruption cut nothing, so the window waits for the transport's report as a done reply's does. + [Fact(Timeout = 30_000)] + public async Task AReplySpokenWhole_KeepsItsBargeWindowOpenThroughAFinalPromptsInterruption() + { + ReplyHearing hearing = await SpokenAsync("hello there caller"); + + hearing.MarkSpokenWhole(); + hearing.ExpectBarge(); + hearing.MarkSpoken(interrupted: true); + + Assert.False(hearing.AwaitsBarge); + Assert.False(hearing.BargeWindowClosed); + Assert.Empty(_port.Cuts); + } + + // The final prompt's interruption landed after the last word and kept the report for a recut that never runs. + [Fact(Timeout = 30_000)] + public async Task AReportKeptBeforeTheReplyWasMarkedSpokenWhole_CutsTheLineAndTheTurn() + { + ReplyHearing hearing = await SpokenAsync("hello there caller"); + hearing.ExpectBarge(); + Assert.True(hearing.RecordBarge("hello there", TimeSpan.FromMilliseconds(400))); + + hearing.MarkSpokenWhole(); + + Assert.Equal("hello there", hearing.HeardText); + Assert.True(hearing.BargeWindowClosed); + Assert.Equal([(0, new TurnCut("hello there", TimeSpan.FromMilliseconds(400)))], _port.Cuts); + } + + private async Task SpokenAsync(string text) + { + EngineReplyStream stream = EngineReplyStream.Start( + _port, + "hi", + Task.CompletedTask, + SpeechHandle.Create(_time, NullLogger.Instance), + new TurnMetrics(_time, userTurnEndedAt: null, static (_, _, _) => { }), + NullLogger.Instance, + CancellationToken.None); + _ = await _port.Turn(1).Started; + + ReplyHearing hearing = new(stream, _time, VoiceOptions.DefaultHeardTextWait, CancellationToken.None); + hearing.MarkFirstText(); + hearing.AddStep(new TextForwardingResult(text, TextPlayback.Full)); + return hearing; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs index 31132e5f..1cc533d3 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs @@ -204,11 +204,11 @@ public async Task ACurrentSpeechThatIsDone_IsNoPendingSpeech() SpeechHandle playing = Schedule(SpeechPriority.Normal); await playing.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); - // The loop clears the current speech under this lock, so it is still current here. + // The loop clears the current speech under this lock, so the loop still holds it here; done, it plays no more. lock (_sessionLock) { playing.MarkDone(); - Assert.Same(playing, _scheduler.CurrentSpeech); + Assert.Null(_scheduler.CurrentSpeech); Assert.True(_scheduler.NoPendingSpeech); } diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs index e386450c..e3e45261 100644 --- a/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs @@ -50,6 +50,20 @@ public async Task AFragmentReadAfterTheInterruption_IsNotForwarded_AndPlaybackIs Assert.Equal(["Once"], output.Spoken); } + // An interruption that lands once the source ran out cut nothing: every fragment reached the output. + [Fact(Timeout = 30_000)] + public async Task AnInterruptionAfterTheLastFragment_IsFull() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + FakeConversationOutput output = new(); + + TextForwardingResult result = await TextForwarding.ForwardAsync( + handle, output, InterruptedAfter(handle, "Once", " upon"), onFirstText: null, handle.TaskCancellationToken); + + Assert.Equal(new TextForwardingResult("Once upon", TextPlayback.Full), result); + Assert.Equal(["Once", " upon"], output.Spoken); + } + [Fact(Timeout = 30_000)] public async Task NothingForwarded_IsSkipped() { @@ -89,6 +103,16 @@ private static async IAsyncEnumerable Fragments(params string[] texts) } } + private static async IAsyncEnumerable InterruptedAfter(SpeechHandle handle, params string[] texts) + { + foreach (string text in texts) + { + yield return text; + } + + _ = handle.Interrupt(); + } + private static async IAsyncEnumerable InterruptedBetween(SpeechHandle handle, string first, string second) { yield return first;