diff --git a/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs b/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs
deleted file mode 100644
index 3491d31..0000000
--- a/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace AgentCore.Application.Conversation.Actions
-{
- /// How an went. A channel reads every value it does not know as .
- public enum CallTransferOutcome
- {
- /// The line did not take the call; the caller is still on this call.
- NotTaken = 0,
-
- /// The line took the call; this side hangs its leg up.
- Taken = 1,
- }
-}
diff --git a/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs b/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs
deleted file mode 100644
index 4a92ab3..0000000
--- a/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace AgentCore.Application.Conversation.Actions
-{
- ///
- /// The base of every action a conversation can be asked for. Its constructor is closed to other assemblies: every
- /// channel must know what each action means, and an action a host made up would reach channels that cannot read it.
- ///
- public abstract record ConversationAction
- {
- private protected ConversationAction()
- {
- }
- }
-}
diff --git a/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs b/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs
deleted file mode 100644
index c2739a3..0000000
--- a/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace AgentCore.Application.Conversation.Actions
-{
- ///
- /// The answer to an action request. It is a value and not an exception, so the tool can turn a refusal into words
- /// for the model; AgentCore writes no text for the model.
- ///
- public enum ConversationActionResult
- {
- /// Accepted. It runs after the current answer was delivered, so a caller hears the answer first.
- Scheduled = 0,
-
- /// The channel has no way to do it, such as a web chat asked to transfer a call. The tool picks the fallback.
- NotSupported = 1,
-
- /// Refused: an end was already asked for, and a later action would contradict it.
- Ending = 2,
- }
-}
diff --git a/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs b/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs
deleted file mode 100644
index 4e354bd..0000000
--- a/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using AgentCore.Domain.Audit;
-
-namespace AgentCore.Application.Conversation.Actions
-{
- ///
- /// The same end as ConversationSession.EndConversation, as an action, so a tool ends its conversation through
- /// the one door it uses for every other action.
- ///
- /// Stored as the conversation.ended token, so it becomes part of the audit record.
- public sealed record EndConversationAction(ConversationEndReason Reason) : ConversationAction;
-}
diff --git a/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs b/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs
deleted file mode 100644
index 56a4c45..0000000
--- a/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace AgentCore.Application.Conversation.Actions
-{
- ///
- /// A host that moves a call to another line itself, such as through its phone system's API. A phone channel that can
- /// transfer (GPT-Live today) hands a registered one each in place of its own transfer,
- /// and takes its answer as the outcome: the vendor's own transfer may report none (OpenAI's SIP refer, probe T1).
- /// Register it as a singleton: a call outlives the request that started it.
- ///
- public interface ICallTransfer
- {
- /// Moves the call. It runs once the caller heard the answer that asked for it.
- /// The call and its target.
- /// Cancels the move.
- /// Whether the line took the call.
- Task TransferAsync(CallTransfer transfer, CancellationToken cancellationToken);
- }
-}
diff --git a/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs b/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs
deleted file mode 100644
index 7596656..0000000
--- a/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace AgentCore.Application.Conversation.Actions
-{
- ///
- /// One door for every action, so a new action needs no new method here and no new parameter on a tool.
- ///
- public interface IConversationControl
- {
- /// Returns at once and runs nothing inline, because an action must wait until the current answer was delivered.
- /// One of the actions AgentCore defines.
- /// Whether the action will run, and if not, why, so the tool can tell the model.
- /// is .
- ConversationActionResult Request(ConversationAction action);
- }
-}
diff --git a/src/AgentCore.Application/Conversation/Commands/AddVoiceContextCommand.cs b/src/AgentCore.Application/Conversation/Commands/AddVoiceContextCommand.cs
new file mode 100644
index 0000000..0b9a6cb
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Commands/AddVoiceContextCommand.cs
@@ -0,0 +1,22 @@
+namespace AgentCore.Application.Conversation.Commands
+{
+ ///
+ /// A fact for a voice that speaks for itself, for example a note a host looked up after the call was answered.
+ ///
+ public sealed record AddVoiceContextCommand : ChannelCommand
+ {
+ private const int MaxLength = 1000;
+
+ /// Keeps the fact, cut to its first 1,000 characters.
+ /// The fact, as the voice should read it.
+ /// is , empty, or white space.
+ public AddVoiceContextCommand(string text)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(text);
+ Text = text.Length > MaxLength ? text[..MaxLength] : text;
+ }
+
+ /// Gets the fact.
+ public string Text { get; }
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Commands/CallTransferOutcome.cs b/src/AgentCore.Application/Conversation/Commands/CallTransferOutcome.cs
new file mode 100644
index 0000000..e09a1f0
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Commands/CallTransferOutcome.cs
@@ -0,0 +1,15 @@
+namespace AgentCore.Application.Conversation.Commands
+{
+ ///
+ /// How a host's transfer went, as its answers it. A channel
+ /// reads every value it does not know as .
+ ///
+ public enum CallTransferOutcome
+ {
+ /// The line did not take the call; the caller is still on this call.
+ NotTaken = 0,
+
+ /// The line took the call; this side hangs its leg up.
+ Taken = 1,
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Commands/ChannelCommand.cs b/src/AgentCore.Application/Conversation/Commands/ChannelCommand.cs
new file mode 100644
index 0000000..6118564
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Commands/ChannelCommand.cs
@@ -0,0 +1,12 @@
+namespace AgentCore.Application.Conversation.Commands
+{
+ ///
+ /// The base of every command a conversation's live channel.
+ ///
+ public abstract record ChannelCommand
+ {
+ private protected ChannelCommand()
+ {
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs b/src/AgentCore.Application/Conversation/Commands/ChannelCommandContext.cs
similarity index 51%
rename from src/AgentCore.Application/Conversation/Actions/CallTransfer.cs
rename to src/AgentCore.Application/Conversation/Commands/ChannelCommandContext.cs
index 00c9f18..a4e537e 100644
--- a/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs
+++ b/src/AgentCore.Application/Conversation/Commands/ChannelCommandContext.cs
@@ -1,14 +1,11 @@
-namespace AgentCore.Application.Conversation.Actions
+namespace AgentCore.Application.Conversation.Commands
{
- /// One transfer an carries out.
- public sealed record CallTransfer
+ /// The conversation an acts on.
+ public sealed record ChannelCommandContext
{
- /// Gets the conversation the call carries.
+ /// Gets the conversation the channel carries.
public required string ConversationId { get; init; }
- /// Gets the line, as the named it.
- public required Uri Target { get; init; }
-
///
/// Gets the call's own transport headers, such as SIP headers, by name, case-insensitive. A host finds its phone
/// system's id for the call here when the system that placed the call put it in.
diff --git a/src/AgentCore.Application/Conversation/Commands/ChannelCommandResult.cs b/src/AgentCore.Application/Conversation/Commands/ChannelCommandResult.cs
new file mode 100644
index 0000000..e64b292
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Commands/ChannelCommandResult.cs
@@ -0,0 +1,17 @@
+namespace AgentCore.Application.Conversation.Commands
+{
+ ///
+ /// The answer to a command.
+ ///
+ public enum ChannelCommandResult
+ {
+ /// Accepted. It runs when its command says, such as after the current answer was delivered.
+ Scheduled = 0,
+
+ /// The channel has no way to do it, such as a web chat asked to transfer a call. The tool picks the fallback.
+ NotSupported = 1,
+
+ /// Refused: an end was already asked for, and a later command would contradict it.
+ Ending = 2,
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Commands/EndCommand.cs b/src/AgentCore.Application/Conversation/Commands/EndCommand.cs
new file mode 100644
index 0000000..7b8e1d4
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Commands/EndCommand.cs
@@ -0,0 +1,10 @@
+using AgentCore.Domain.Audit;
+
+namespace AgentCore.Application.Conversation.Commands
+{
+ ///
+ /// The same end as ConversationSession.EndConversation, as a command.
+ ///
+ /// Stored as the conversation.ended token, so it becomes part of the audit record.
+ public sealed record EndCommand(ConversationEndReason Reason) : ChannelCommand;
+}
diff --git a/src/AgentCore.Application/Conversation/Commands/IChannelCommandHandler.cs b/src/AgentCore.Application/Conversation/Commands/IChannelCommandHandler.cs
new file mode 100644
index 0000000..5c8ad5c
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Commands/IChannelCommandHandler.cs
@@ -0,0 +1,18 @@
+namespace AgentCore.Application.Conversation.Commands
+{
+ ///
+ /// A host that carries out one command itself.
+ ///
+ /// The command the host carries out.
+ /// What the channel needs to know of how it went, such as .
+ public interface IChannelCommandHandler
+ where TCommand : ChannelCommand
+ {
+ /// Carries out the command. A transfer runs once the caller heard the answer that asked for it.
+ /// The command, as the tool sent it.
+ /// The conversation the command acts on.
+ /// Cancels the command.
+ /// How it went.
+ Task HandleAsync(TCommand command, ChannelCommandContext context, CancellationToken cancellationToken);
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Commands/IChannelControl.cs b/src/AgentCore.Application/Conversation/Commands/IChannelControl.cs
new file mode 100644
index 0000000..f8f4bde
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Commands/IChannelControl.cs
@@ -0,0 +1,14 @@
+namespace AgentCore.Application.Conversation.Commands
+{
+ ///
+ /// One door for every command.
+ ///
+ public interface IChannelControl
+ {
+ /// Returns at once and runs nothing inline: each command runs when it says, such as after the current answer was delivered.
+ /// One of the commands AgentCore defines.
+ /// Whether the command will run, and if not, why, so the tool can tell the model.
+ /// is .
+ ChannelCommandResult Send(ChannelCommand command);
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Actions/TransferAction.cs b/src/AgentCore.Application/Conversation/Commands/TransferCommand.cs
similarity index 62%
rename from src/AgentCore.Application/Conversation/Actions/TransferAction.cs
rename to src/AgentCore.Application/Conversation/Commands/TransferCommand.cs
index f7f4756..512279b 100644
--- a/src/AgentCore.Application/Conversation/Actions/TransferAction.cs
+++ b/src/AgentCore.Application/Conversation/Commands/TransferCommand.cs
@@ -1,16 +1,15 @@
-namespace AgentCore.Application.Conversation.Actions
+namespace AgentCore.Application.Conversation.Commands
{
///
- /// Hands a phone call to another line. It waits until the caller heard the current answer, because a transfer that
- /// cuts the reply loses what the caller was just told.
+ /// Hands a phone call to another line.
///
- public sealed record TransferAction : ConversationAction
+ public sealed record TransferCommand : ChannelCommand
{
/// Checks the target here, so a bad target fails in the tool that made it and not later on the call.
/// A tel:, sip: or sips: URI, the forms a SIP REFER can carry.
/// is .
/// is not an absolute tel, sip or sips URI.
- public TransferAction(Uri target)
+ public TransferCommand(Uri target)
{
ArgumentNullException.ThrowIfNull(target);
if (!target.IsAbsoluteUri || target.Scheme is not ("tel" or "sip" or "sips"))
@@ -25,10 +24,7 @@ public TransferAction(Uri target)
public Uri Target { get; }
///
- /// Gets the instruction the voice follows when the line did not take the call, after which the call goes on. The
- /// host writes it, because AgentCore writes no text for the model. Without it a failed transfer hangs up. A voice
- /// may keep it for the rest of the call (GPT-Live appends it to its session instructions, as it does the
- /// greeting), so word it as one past event, such as "the transfer you just started did not go through".
+ /// Gets the instruction the voice follows when the line did not take the call, after which the call goes on.
///
public string? IfFailed { get; init; }
}
diff --git a/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs
index 88d9c11..a0db560 100644
--- a/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs
+++ b/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs
@@ -6,7 +6,6 @@ namespace AgentCore.Application.Hooks.BuiltIn
{
///
/// Adds an accepted call's brief to every run of the call's conversation, as instructions.
- /// AgentCore never reads, wraps, or changes the brief; the host writes all of it.
///
internal sealed class CallBriefHook : AgentHook
{
diff --git a/src/AgentCore.Application/Hooks/Gates/CallGate.cs b/src/AgentCore.Application/Hooks/Gates/CallGate.cs
index 682d91d..ad029aa 100644
--- a/src/AgentCore.Application/Hooks/Gates/CallGate.cs
+++ b/src/AgentCore.Application/Hooks/Gates/CallGate.cs
@@ -60,6 +60,7 @@ public void Accept(string conversationId, string? brief = null)
ArgumentException.ThrowIfNullOrEmpty(conversationId);
string? kept = brief is { Length: > MaxBriefLength } ? brief[..MaxBriefLength] : brief;
+
Stage(terminal: true, () =>
{
AcceptedConversationId = conversationId;
diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt
index 0cb2fd6..e7934d9 100644
--- a/src/AgentCore.Application/PublicAPI.Unshipped.txt
+++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt
@@ -3114,72 +3114,81 @@ override sealed AgentCore.Application.Hooks.Notices.CallEnded.Equals(AgentCore.A
static AgentCore.Application.Hooks.Notices.CallEnded.operator !=(AgentCore.Application.Hooks.Notices.CallEnded? left, AgentCore.Application.Hooks.Notices.CallEnded? right) -> bool
static AgentCore.Application.Hooks.Notices.CallEnded.operator ==(AgentCore.Application.Hooks.Notices.CallEnded? left, AgentCore.Application.Hooks.Notices.CallEnded? right) -> bool
virtual AgentCore.Application.Hooks.AgentHook.OnCallEndedAsync(AgentCore.Application.Hooks.Notices.CallEnded! notice, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
-AgentCore.Application.Conversation.Actions.ConversationAction
-AgentCore.Application.Conversation.Actions.ConversationAction.ConversationAction(AgentCore.Application.Conversation.Actions.ConversationAction! original) -> void
-AgentCore.Application.Conversation.Actions.ConversationActionResult
-AgentCore.Application.Conversation.Actions.ConversationActionResult.Ending = 2 -> AgentCore.Application.Conversation.Actions.ConversationActionResult
-AgentCore.Application.Conversation.Actions.ConversationActionResult.NotSupported = 1 -> AgentCore.Application.Conversation.Actions.ConversationActionResult
-AgentCore.Application.Conversation.Actions.ConversationActionResult.Scheduled = 0 -> AgentCore.Application.Conversation.Actions.ConversationActionResult
-AgentCore.Application.Conversation.Actions.EndConversationAction
-AgentCore.Application.Conversation.Actions.EndConversationAction.Deconstruct(out AgentCore.Domain.Audit.ConversationEndReason Reason) -> void
-AgentCore.Application.Conversation.Actions.EndConversationAction.EndConversationAction(AgentCore.Domain.Audit.ConversationEndReason Reason) -> void
-AgentCore.Application.Conversation.Actions.EndConversationAction.Equals(AgentCore.Application.Conversation.Actions.EndConversationAction? other) -> bool
-AgentCore.Application.Conversation.Actions.EndConversationAction.Reason.get -> AgentCore.Domain.Audit.ConversationEndReason
-AgentCore.Application.Conversation.Actions.EndConversationAction.Reason.init -> void
-AgentCore.Application.Conversation.Actions.IConversationControl
-AgentCore.Application.Conversation.Actions.IConversationControl.Request(AgentCore.Application.Conversation.Actions.ConversationAction! action) -> AgentCore.Application.Conversation.Actions.ConversationActionResult
-AgentCore.Application.Conversation.Actions.TransferAction
-AgentCore.Application.Conversation.Actions.TransferAction.Equals(AgentCore.Application.Conversation.Actions.TransferAction? other) -> bool
-AgentCore.Application.Conversation.Actions.TransferAction.Target.get -> System.Uri!
-AgentCore.Application.Conversation.Actions.TransferAction.TransferAction(System.Uri! target) -> void
-AgentCore.Application.Runtime.Session.ConversationSession.Request(AgentCore.Application.Conversation.Actions.ConversationAction! action) -> AgentCore.Application.Conversation.Actions.ConversationActionResult
-AgentCore.Application.Tools.ToolCallScope.Conversation.get -> AgentCore.Application.Conversation.Actions.IConversationControl!
-AgentCore.Application.Tools.ToolCallScope.Conversation.init -> void
-abstract AgentCore.Application.Conversation.Actions.ConversationAction.$() -> AgentCore.Application.Conversation.Actions.ConversationAction!
-override AgentCore.Application.Conversation.Actions.ConversationAction.Equals(object? obj) -> bool
-override AgentCore.Application.Conversation.Actions.ConversationAction.GetHashCode() -> int
-override AgentCore.Application.Conversation.Actions.ConversationAction.ToString() -> string!
-override AgentCore.Application.Conversation.Actions.EndConversationAction.$() -> AgentCore.Application.Conversation.Actions.EndConversationAction!
-override AgentCore.Application.Conversation.Actions.EndConversationAction.Equals(object? obj) -> bool
-override AgentCore.Application.Conversation.Actions.EndConversationAction.GetHashCode() -> int
-override AgentCore.Application.Conversation.Actions.EndConversationAction.ToString() -> string!
-override AgentCore.Application.Conversation.Actions.TransferAction.$() -> AgentCore.Application.Conversation.Actions.TransferAction!
-override AgentCore.Application.Conversation.Actions.TransferAction.Equals(object? obj) -> bool
-override AgentCore.Application.Conversation.Actions.TransferAction.GetHashCode() -> int
-override AgentCore.Application.Conversation.Actions.TransferAction.ToString() -> string!
-override sealed AgentCore.Application.Conversation.Actions.EndConversationAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool
-override sealed AgentCore.Application.Conversation.Actions.TransferAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool
-static AgentCore.Application.Conversation.Actions.ConversationAction.operator ==(AgentCore.Application.Conversation.Actions.ConversationAction? left, AgentCore.Application.Conversation.Actions.ConversationAction? right) -> bool
-static AgentCore.Application.Conversation.Actions.ConversationAction.operator !=(AgentCore.Application.Conversation.Actions.ConversationAction? left, AgentCore.Application.Conversation.Actions.ConversationAction? right) -> bool
-static AgentCore.Application.Conversation.Actions.EndConversationAction.operator ==(AgentCore.Application.Conversation.Actions.EndConversationAction? left, AgentCore.Application.Conversation.Actions.EndConversationAction? right) -> bool
-static AgentCore.Application.Conversation.Actions.EndConversationAction.operator !=(AgentCore.Application.Conversation.Actions.EndConversationAction? left, AgentCore.Application.Conversation.Actions.EndConversationAction? right) -> bool
-static AgentCore.Application.Conversation.Actions.TransferAction.operator ==(AgentCore.Application.Conversation.Actions.TransferAction? left, AgentCore.Application.Conversation.Actions.TransferAction? right) -> bool
-static AgentCore.Application.Conversation.Actions.TransferAction.operator !=(AgentCore.Application.Conversation.Actions.TransferAction? left, AgentCore.Application.Conversation.Actions.TransferAction? right) -> bool
-virtual AgentCore.Application.Conversation.Actions.ConversationAction.EqualityContract.get -> System.Type!
-virtual AgentCore.Application.Conversation.Actions.ConversationAction.Equals(AgentCore.Application.Conversation.Actions.ConversationAction? other) -> bool
-virtual AgentCore.Application.Conversation.Actions.ConversationAction.PrintMembers(System.Text.StringBuilder! builder) -> bool
const AgentCore.Application.Secrets.KnownSecrets.TelnyxRelayKeyName = "telnyx-relay-key" -> string!
const AgentCore.Application.Secrets.KnownSecrets.TelnyxRelayKeyVariable = "TELNYX_RELAY_KEY" -> string!
static readonly AgentCore.Application.Secrets.KnownSecrets.TelnyxRelay -> AgentCore.Application.Secrets.SecretName!
-AgentCore.Application.Conversation.Actions.TransferAction.IfFailed.get -> string?
-AgentCore.Application.Conversation.Actions.TransferAction.IfFailed.init -> void
-AgentCore.Application.Conversation.Actions.CallTransfer
-AgentCore.Application.Conversation.Actions.CallTransfer.$() -> AgentCore.Application.Conversation.Actions.CallTransfer!
-AgentCore.Application.Conversation.Actions.CallTransfer.CallTransfer() -> void
-AgentCore.Application.Conversation.Actions.CallTransfer.ConversationId.get -> string!
-AgentCore.Application.Conversation.Actions.CallTransfer.ConversationId.init -> void
-AgentCore.Application.Conversation.Actions.CallTransfer.Equals(AgentCore.Application.Conversation.Actions.CallTransfer? other) -> bool
-AgentCore.Application.Conversation.Actions.CallTransfer.Headers.get -> System.Collections.Generic.IReadOnlyDictionary!
-AgentCore.Application.Conversation.Actions.CallTransfer.Headers.init -> void
-AgentCore.Application.Conversation.Actions.CallTransferOutcome
-AgentCore.Application.Conversation.Actions.CallTransferOutcome.NotTaken = 0 -> AgentCore.Application.Conversation.Actions.CallTransferOutcome
-AgentCore.Application.Conversation.Actions.CallTransferOutcome.Taken = 1 -> AgentCore.Application.Conversation.Actions.CallTransferOutcome
-AgentCore.Application.Conversation.Actions.CallTransfer.Target.get -> System.Uri!
-AgentCore.Application.Conversation.Actions.CallTransfer.Target.init -> void
-AgentCore.Application.Conversation.Actions.ICallTransfer
-AgentCore.Application.Conversation.Actions.ICallTransfer.TransferAsync(AgentCore.Application.Conversation.Actions.CallTransfer! transfer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
-override AgentCore.Application.Conversation.Actions.CallTransfer.Equals(object? obj) -> bool
-override AgentCore.Application.Conversation.Actions.CallTransfer.GetHashCode() -> int
-override AgentCore.Application.Conversation.Actions.CallTransfer.ToString() -> string!
-static AgentCore.Application.Conversation.Actions.CallTransfer.operator ==(AgentCore.Application.Conversation.Actions.CallTransfer? left, AgentCore.Application.Conversation.Actions.CallTransfer? right) -> bool
-static AgentCore.Application.Conversation.Actions.CallTransfer.operator !=(AgentCore.Application.Conversation.Actions.CallTransfer? left, AgentCore.Application.Conversation.Actions.CallTransfer? right) -> bool
+abstract AgentCore.Application.Conversation.Commands.ChannelCommand.$() -> AgentCore.Application.Conversation.Commands.ChannelCommand!
+AgentCore.Application.Conversation.Commands.AddVoiceContextCommand
+AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.AddVoiceContextCommand(string! text) -> void
+AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.Equals(AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? other) -> bool
+AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.Text.get -> string!
+AgentCore.Application.Conversation.Commands.CallTransferOutcome
+AgentCore.Application.Conversation.Commands.CallTransferOutcome.NotTaken = 0 -> AgentCore.Application.Conversation.Commands.CallTransferOutcome
+AgentCore.Application.Conversation.Commands.CallTransferOutcome.Taken = 1 -> AgentCore.Application.Conversation.Commands.CallTransferOutcome
+AgentCore.Application.Conversation.Commands.ChannelCommand
+AgentCore.Application.Conversation.Commands.ChannelCommand.ChannelCommand(AgentCore.Application.Conversation.Commands.ChannelCommand! original) -> void
+AgentCore.Application.Conversation.Commands.ChannelCommandContext
+AgentCore.Application.Conversation.Commands.ChannelCommandContext.$() -> AgentCore.Application.Conversation.Commands.ChannelCommandContext!
+AgentCore.Application.Conversation.Commands.ChannelCommandContext.ChannelCommandContext() -> void
+AgentCore.Application.Conversation.Commands.ChannelCommandContext.ConversationId.get -> string!
+AgentCore.Application.Conversation.Commands.ChannelCommandContext.ConversationId.init -> void
+AgentCore.Application.Conversation.Commands.ChannelCommandContext.Equals(AgentCore.Application.Conversation.Commands.ChannelCommandContext? other) -> bool
+AgentCore.Application.Conversation.Commands.ChannelCommandContext.Headers.get -> System.Collections.Generic.IReadOnlyDictionary!
+AgentCore.Application.Conversation.Commands.ChannelCommandContext.Headers.init -> void
+AgentCore.Application.Conversation.Commands.ChannelCommandResult
+AgentCore.Application.Conversation.Commands.ChannelCommandResult.Ending = 2 -> AgentCore.Application.Conversation.Commands.ChannelCommandResult
+AgentCore.Application.Conversation.Commands.ChannelCommandResult.NotSupported = 1 -> AgentCore.Application.Conversation.Commands.ChannelCommandResult
+AgentCore.Application.Conversation.Commands.ChannelCommandResult.Scheduled = 0 -> AgentCore.Application.Conversation.Commands.ChannelCommandResult
+AgentCore.Application.Conversation.Commands.EndCommand
+AgentCore.Application.Conversation.Commands.EndCommand.Deconstruct(out AgentCore.Domain.Audit.ConversationEndReason Reason) -> void
+AgentCore.Application.Conversation.Commands.EndCommand.EndCommand(AgentCore.Domain.Audit.ConversationEndReason Reason) -> void
+AgentCore.Application.Conversation.Commands.EndCommand.Equals(AgentCore.Application.Conversation.Commands.EndCommand? other) -> bool
+AgentCore.Application.Conversation.Commands.EndCommand.Reason.get -> AgentCore.Domain.Audit.ConversationEndReason
+AgentCore.Application.Conversation.Commands.EndCommand.Reason.init -> void
+AgentCore.Application.Conversation.Commands.IChannelCommandHandler
+AgentCore.Application.Conversation.Commands.IChannelCommandHandler.HandleAsync(TCommand! command, AgentCore.Application.Conversation.Commands.ChannelCommandContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
+AgentCore.Application.Conversation.Commands.IChannelControl
+AgentCore.Application.Conversation.Commands.IChannelControl.Send(AgentCore.Application.Conversation.Commands.ChannelCommand! command) -> AgentCore.Application.Conversation.Commands.ChannelCommandResult
+AgentCore.Application.Conversation.Commands.TransferCommand
+AgentCore.Application.Conversation.Commands.TransferCommand.Equals(AgentCore.Application.Conversation.Commands.TransferCommand? other) -> bool
+AgentCore.Application.Conversation.Commands.TransferCommand.IfFailed.get -> string?
+AgentCore.Application.Conversation.Commands.TransferCommand.IfFailed.init -> void
+AgentCore.Application.Conversation.Commands.TransferCommand.Target.get -> System.Uri!
+AgentCore.Application.Conversation.Commands.TransferCommand.TransferCommand(System.Uri! target) -> void
+AgentCore.Application.Runtime.Session.ConversationSession.Send(AgentCore.Application.Conversation.Commands.ChannelCommand! command) -> AgentCore.Application.Conversation.Commands.ChannelCommandResult
+AgentCore.Application.Tools.ToolCallScope.Channel.get -> AgentCore.Application.Conversation.Commands.IChannelControl!
+AgentCore.Application.Tools.ToolCallScope.Channel.init -> void
+override AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.$() -> AgentCore.Application.Conversation.Commands.AddVoiceContextCommand!
+override AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.Equals(object? obj) -> bool
+override AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.GetHashCode() -> int
+override AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.ToString() -> string!
+override AgentCore.Application.Conversation.Commands.ChannelCommandContext.Equals(object? obj) -> bool
+override AgentCore.Application.Conversation.Commands.ChannelCommandContext.GetHashCode() -> int
+override AgentCore.Application.Conversation.Commands.ChannelCommandContext.ToString() -> string!
+override AgentCore.Application.Conversation.Commands.ChannelCommand.Equals(object? obj) -> bool
+override AgentCore.Application.Conversation.Commands.ChannelCommand.GetHashCode() -> int
+override AgentCore.Application.Conversation.Commands.ChannelCommand.ToString() -> string!
+override AgentCore.Application.Conversation.Commands.EndCommand.$() -> AgentCore.Application.Conversation.Commands.EndCommand!
+override AgentCore.Application.Conversation.Commands.EndCommand.Equals(object? obj) -> bool
+override AgentCore.Application.Conversation.Commands.EndCommand.GetHashCode() -> int
+override AgentCore.Application.Conversation.Commands.EndCommand.ToString() -> string!
+override AgentCore.Application.Conversation.Commands.TransferCommand.$() -> AgentCore.Application.Conversation.Commands.TransferCommand!
+override AgentCore.Application.Conversation.Commands.TransferCommand.Equals(object? obj) -> bool
+override AgentCore.Application.Conversation.Commands.TransferCommand.GetHashCode() -> int
+override AgentCore.Application.Conversation.Commands.TransferCommand.ToString() -> string!
+override sealed AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.Equals(AgentCore.Application.Conversation.Commands.ChannelCommand? other) -> bool
+override sealed AgentCore.Application.Conversation.Commands.EndCommand.Equals(AgentCore.Application.Conversation.Commands.ChannelCommand? other) -> bool
+override sealed AgentCore.Application.Conversation.Commands.TransferCommand.Equals(AgentCore.Application.Conversation.Commands.ChannelCommand? other) -> bool
+static AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.operator ==(AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? left, AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? right) -> bool
+static AgentCore.Application.Conversation.Commands.AddVoiceContextCommand.operator !=(AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? left, AgentCore.Application.Conversation.Commands.AddVoiceContextCommand? right) -> bool
+static AgentCore.Application.Conversation.Commands.ChannelCommandContext.operator ==(AgentCore.Application.Conversation.Commands.ChannelCommandContext? left, AgentCore.Application.Conversation.Commands.ChannelCommandContext? right) -> bool
+static AgentCore.Application.Conversation.Commands.ChannelCommandContext.operator !=(AgentCore.Application.Conversation.Commands.ChannelCommandContext? left, AgentCore.Application.Conversation.Commands.ChannelCommandContext? right) -> bool
+static AgentCore.Application.Conversation.Commands.ChannelCommand.operator ==(AgentCore.Application.Conversation.Commands.ChannelCommand? left, AgentCore.Application.Conversation.Commands.ChannelCommand? right) -> bool
+static AgentCore.Application.Conversation.Commands.ChannelCommand.operator !=(AgentCore.Application.Conversation.Commands.ChannelCommand? left, AgentCore.Application.Conversation.Commands.ChannelCommand? right) -> bool
+static AgentCore.Application.Conversation.Commands.EndCommand.operator ==(AgentCore.Application.Conversation.Commands.EndCommand? left, AgentCore.Application.Conversation.Commands.EndCommand? right) -> bool
+static AgentCore.Application.Conversation.Commands.EndCommand.operator !=(AgentCore.Application.Conversation.Commands.EndCommand? left, AgentCore.Application.Conversation.Commands.EndCommand? right) -> bool
+static AgentCore.Application.Conversation.Commands.TransferCommand.operator ==(AgentCore.Application.Conversation.Commands.TransferCommand? left, AgentCore.Application.Conversation.Commands.TransferCommand? right) -> bool
+static AgentCore.Application.Conversation.Commands.TransferCommand.operator !=(AgentCore.Application.Conversation.Commands.TransferCommand? left, AgentCore.Application.Conversation.Commands.TransferCommand? right) -> bool
+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
diff --git a/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs
index a83a1a3..edadbf3 100644
--- a/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs
+++ b/src/AgentCore.Application/Runtime/Agents/ConversationTurnRunner.cs
@@ -62,9 +62,11 @@ internal void SetTimeZone(TimeZoneInfo zone)
internal string? ClockLine()
{
AgentsConfiguration agents = _session.Compiled.Configuration.Agents;
+
string? agentId = _session.Policy is { } policy
? policy.CurrentAgentId
: _session.Compiled.Configuration.Entries.GetValueOrDefault(_session.Compiled.EntryName)?.Agent;
+
bool clock = agents.Items.FirstOrDefault(agent => agent.Id == agentId) is { } named
? AgentHarness.Compose(agents.Defaults, named).Clock
: agents.Defaults?.Clock ?? true;
@@ -143,7 +145,7 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session,
// An edit that withdraws the turn that asked leaves its requests unanswered: the edit drops them.
IReadOnlySet withdrawn = TurnApprovalAnswers.Withdrawn(_session.History, session, origin);
-
+
ChatMessage spoken = !userInput.Contents.OfType().Any()
&& PendingApprovalQueue.Open(session).Where(request => !withdrawn.Contains(request.RequestId)).ToList() is { Count: > 0 } open
? TurnApprovalAnswers.MovedOn(userInput, open)
@@ -265,7 +267,7 @@ internal TurnInvocation TurnInvocationOf(ConversationTurn turn, ITurnCompleter c
ToolRuns = _session.ToolRuns,
Notices = new TurnNotices(),
Hooks = _session.Hooks,
- Control = _session.Actions,
+ Channel = _session.Commands,
RefusalReply = _session.Compiled.RefusalReply,
Items = new ConcurrentDictionary(StringComparer.Ordinal),
Rounds = new TurnRounds(),
diff --git a/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs b/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs
index 5adc44a..71415d7 100644
--- a/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs
+++ b/src/AgentCore.Application/Runtime/Harness/BackgroundChildAgent.cs
@@ -55,7 +55,7 @@ private static TurnInvocation Shared(TurnInvocation parent)
TimeZone = parent.TimeZone,
ToolStop = parent.ToolStop,
ToolRuns = parent.ToolRuns,
- Control = parent.Control,
+ Channel = parent.Channel,
Nested = true,
};
}
diff --git a/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs b/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs
new file mode 100644
index 0000000..ff69309
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/Session/ChannelCommands.cs
@@ -0,0 +1,37 @@
+using AgentCore.Application.Conversation.Commands;
+
+namespace AgentCore.Application.Runtime.Session
+{
+ ///
+ /// The end is handled here and not by the channel, because every channel already follows an end the engine asks for.
+ /// Only a command the transport must carry out itself goes to the channel.
+ ///
+ internal sealed class ChannelCommands(ConversationSession session) : IChannelControl
+ {
+ private IConversationChannel? _channel;
+
+ /// Replaces any earlier channel, because a call that takes a conversation over carries its commands from then on.
+ internal void Attach(IConversationChannel channel)
+ {
+ ArgumentNullException.ThrowIfNull(channel);
+ Volatile.Write(ref _channel, channel);
+ }
+
+ ///
+ public ChannelCommandResult Send(ChannelCommand command)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+
+ if (session.IsComplete || session.Lifetime.Ending.Requested)
+ {
+ return ChannelCommandResult.Ending;
+ }
+
+ return command switch
+ {
+ EndCommand end => session.Lifetime.EndConversation(end.Reason) ? ChannelCommandResult.Scheduled : ChannelCommandResult.Ending,
+ _ => Volatile.Read(ref _channel)?.Send(command) ?? ChannelCommandResult.NotSupported,
+ };
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/Session/ConversationActions.cs b/src/AgentCore.Application/Runtime/Session/ConversationActions.cs
deleted file mode 100644
index 0aefe8c..0000000
--- a/src/AgentCore.Application/Runtime/Session/ConversationActions.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using AgentCore.Application.Conversation.Actions;
-
-namespace AgentCore.Application.Runtime.Session
-{
- ///
- /// The end is handled here and not by the channel, because every channel already follows an end the engine asks for.
- /// Only an action the transport must perform itself goes to the channel.
- ///
- internal sealed class ConversationActions(ConversationSession session) : IConversationControl
- {
- private IConversationChannel? _channel;
-
- /// Replaces any earlier channel, because a call that takes a conversation over carries its actions from then on.
- internal void Attach(IConversationChannel channel)
- {
- ArgumentNullException.ThrowIfNull(channel);
- Volatile.Write(ref _channel, channel);
- }
-
- ///
- public ConversationActionResult Request(ConversationAction action)
- {
- ArgumentNullException.ThrowIfNull(action);
- if (session.IsComplete || session.Lifetime.Ending.Requested)
- {
- return ConversationActionResult.Ending;
- }
-
- return action switch
- {
- EndConversationAction end => session.Lifetime.EndConversation(end.Reason) ? ConversationActionResult.Scheduled : ConversationActionResult.Ending,
- _ => Volatile.Read(ref _channel)?.Request(action) ?? ConversationActionResult.NotSupported,
- };
- }
- }
-}
diff --git a/src/AgentCore.Application/Runtime/Session/ConversationSession.cs b/src/AgentCore.Application/Runtime/Session/ConversationSession.cs
index bad6675..f7561bf 100644
--- a/src/AgentCore.Application/Runtime/Session/ConversationSession.cs
+++ b/src/AgentCore.Application/Runtime/Session/ConversationSession.cs
@@ -3,7 +3,7 @@
using AgentCore.Application.Configuration.Schema;
using AgentCore.Application.Configuration.Validation;
using AgentCore.Application.Conversation;
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Hooks.Engine;
using AgentCore.Application.Hooks.Notices;
using AgentCore.Application.Policy;
@@ -103,7 +103,7 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable
internal FinishedToolPairs ToolPairs { get; }
- internal ConversationActions Actions { get; }
+ internal ChannelCommands Commands { get; }
///
/// Creates the session of one conversation.
@@ -146,7 +146,7 @@ internal ConversationSession(
Busy = new ConversationBusyMark(this);
ToolPairs = new FinishedToolPairs(this);
ToolRuns = new ConversationToolRuns(ToolPairs.Keep);
- Actions = new ConversationActions(this);
+ Commands = new ChannelCommands(this);
}
///
@@ -354,10 +354,10 @@ public bool EndConversation(ConversationEndReason reason)
return Lifetime.EndConversation(reason);
}
- ///
- public ConversationActionResult Request(ConversationAction action)
+ ///
+ public ChannelCommandResult Send(ChannelCommand command)
{
- return Actions.Request(action);
+ return Commands.Send(command);
}
///
diff --git a/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs b/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs
index 7d0ffd0..621506d 100644
--- a/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs
+++ b/src/AgentCore.Application/Runtime/Session/IConversationChannel.cs
@@ -1,11 +1,11 @@
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
namespace AgentCore.Application.Runtime.Session
{
- /// Implemented by the transport, because only the transport can perform an action such as a transfer.
+ /// Implemented by the transport, because only the transport can carry out a command such as a transfer.
internal interface IConversationChannel
{
- /// Must not block: a tool call is waiting on it, and the action itself runs after the answer was delivered.
- ConversationActionResult Request(ConversationAction action);
+ /// Must not block: a tool call is waiting on it, and the command itself runs when it says.
+ ChannelCommandResult Send(ChannelCommand command);
}
}
diff --git a/src/AgentCore.Application/Runtime/Session/UnattachedChannel.cs b/src/AgentCore.Application/Runtime/Session/UnattachedChannel.cs
new file mode 100644
index 0000000..24294a6
--- /dev/null
+++ b/src/AgentCore.Application/Runtime/Session/UnattachedChannel.cs
@@ -0,0 +1,21 @@
+using AgentCore.Application.Conversation.Commands;
+
+namespace AgentCore.Application.Runtime.Session
+{
+ /// Keeps ToolCallScope.Channel non-null, so a tool never checks for null; outside a turn there is nothing to act on.
+ internal sealed class UnattachedChannel : IChannelControl
+ {
+ internal static readonly UnattachedChannel Instance = new();
+
+ private UnattachedChannel()
+ {
+ }
+
+ ///
+ public ChannelCommandResult Send(ChannelCommand command)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ return ChannelCommandResult.NotSupported;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs b/src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs
deleted file mode 100644
index 2417b3e..0000000
--- a/src/AgentCore.Application/Runtime/Session/UnattachedConversationControl.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using AgentCore.Application.Conversation.Actions;
-
-namespace AgentCore.Application.Runtime.Session
-{
- /// Keeps ToolCallScope.Conversation non-null, so a tool never checks for null; outside a turn there is nothing to act on.
- internal sealed class UnattachedConversationControl : IConversationControl
- {
- internal static readonly UnattachedConversationControl Instance = new();
-
- private UnattachedConversationControl()
- {
- }
-
- ///
- public ConversationActionResult Request(ConversationAction action)
- {
- ArgumentNullException.ThrowIfNull(action);
- return ConversationActionResult.NotSupported;
- }
- }
-}
diff --git a/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs b/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs
index 5faf88c..16fb1c2 100644
--- a/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs
+++ b/src/AgentCore.Application/Runtime/ToolCalls/ToolCallScopes.cs
@@ -24,7 +24,7 @@ internal static ToolCallScope From(TurnInvocation invocation)
return new ToolCallScope(invocation.ConversationId, invocation.TurnIndex, invocation.Stage, invocation.Workspace)
{
Items = invocation.Items,
- Conversation = invocation.Control ?? UnattachedConversationControl.Instance,
+ Channel = invocation.Channel ?? UnattachedChannel.Instance,
};
}
}
diff --git a/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs b/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs
index 92f5d3a..7e1863f 100644
--- a/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs
+++ b/src/AgentCore.Application/Runtime/Turn/TurnInvocation.cs
@@ -1,5 +1,5 @@
using System.Collections.Concurrent;
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Hooks.Engine;
using AgentCore.Application.Runtime.Harness;
using AgentCore.Application.State;
@@ -79,7 +79,7 @@ internal sealed record TurnInvocation
public SessionHooks? Hooks { get; init; }
/// Carried here so that a tool's scope and a background child's tools reach the same session door.
- public IConversationControl? Control { get; init; }
+ public IChannelControl? Channel { get; init; }
///
/// Gets the turn's shared state, which gates expose as Items and tools as ,
diff --git a/src/AgentCore.Application/Tools/ToolCallScope.cs b/src/AgentCore.Application/Tools/ToolCallScope.cs
index 8687db1..7ade9fc 100644
--- a/src/AgentCore.Application/Tools/ToolCallScope.cs
+++ b/src/AgentCore.Application/Tools/ToolCallScope.cs
@@ -1,4 +1,4 @@
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Runtime.Session;
namespace AgentCore.Application.Tools
@@ -17,9 +17,10 @@ public sealed record ToolCallScope(string ConversationId, int TurnIndex, string
public IDictionary? Items { get; init; }
///
- /// Gets the conversation's action door. It sits on the scope because the binder already hands every tool the
- /// scope, so a tool needs no new parameter. Outside a turn it refuses every action: there is no conversation.
+ /// Gets the door to the conversation's live channel, its call or its chat connection. It sits on the scope because
+ /// the binder already hands every tool the scope, so a tool needs no new parameter. Outside a turn it refuses
+ /// every command: there is no conversation.
///
- public IConversationControl Conversation { get; init; } = UnattachedConversationControl.Instance;
+ public IChannelControl Channel { get; init; } = UnattachedChannel.Instance;
}
}
diff --git a/src/AgentCore.AspNetCore/Calls/CallBrief.cs b/src/AgentCore.AspNetCore/Calls/CallBrief.cs
index 303fc8e..3f85ec2 100644
--- a/src/AgentCore.AspNetCore/Calls/CallBrief.cs
+++ b/src/AgentCore.AspNetCore/Calls/CallBrief.cs
@@ -24,7 +24,10 @@ internal void File()
}
}
- internal void Clear() => host.Briefs?.Clear(conversationId);
+ internal void Clear()
+ {
+ host.Briefs?.Clear(conversationId);
+ }
// A newer session that another call holds carries that call's brief, so only then is it left in place.
internal void Forget(ConversationSession? holder, ConversationSession own)
diff --git a/src/AgentCore.AspNetCore/Calls/CallChannel.cs b/src/AgentCore.AspNetCore/Calls/CallChannel.cs
index 6e3962e..1a126d2 100644
--- a/src/AgentCore.AspNetCore/Calls/CallChannel.cs
+++ b/src/AgentCore.AspNetCore/Calls/CallChannel.cs
@@ -3,8 +3,8 @@
namespace AgentCore.AspNetCore.Calls
{
///
- /// The vendor's actions, such as a transfer, on the call's conversation. It follows the call to a session reopened
- /// after an idle unload, because the tools of the next ask run on that session.
+ /// The vendor's channel, which carries out commands such as a transfer, on the call's conversation. It follows the
+ /// call to a session reopened after an idle unload, because the tools of the next ask run on that session.
///
internal sealed class CallChannel
{
@@ -15,14 +15,14 @@ internal void Use(IConversationChannel channel, ConversationSession session)
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(session);
Volatile.Write(ref _channel, channel);
- session.Actions.Attach(channel);
+ session.Commands.Attach(channel);
}
internal void Follow(ConversationSession reopened)
{
if (Volatile.Read(ref _channel) is { } channel)
{
- reopened.Actions.Attach(channel);
+ reopened.Commands.Attach(channel);
}
}
}
diff --git a/src/AgentCore.AspNetCore/Calls/PhoneCall.cs b/src/AgentCore.AspNetCore/Calls/PhoneCall.cs
index 8b411ad..f641786 100644
--- a/src/AgentCore.AspNetCore/Calls/PhoneCall.cs
+++ b/src/AgentCore.AspNetCore/Calls/PhoneCall.cs
@@ -161,6 +161,7 @@ internal async ValueTask StartAsync(CancellationToken cancellationToken)
_keptStart = kept;
Volatile.Write(ref _session, session);
+ Channel.Follow(session);
}
// ConversationStarted is raised when the store opens, so it always comes before CallStarted.
diff --git a/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs b/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs
index 00100e3..98d68f0 100644
--- a/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs
+++ b/src/AgentCore.AspNetCore/Calls/PhoneCallHost.cs
@@ -1,4 +1,5 @@
using AgentCore.Application.Configuration.Schema;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Hooks;
using AgentCore.Application.Hooks.BuiltIn;
using AgentCore.Application.Hooks.Engine;
@@ -31,6 +32,19 @@ internal sealed record PhoneCallHost(
///
internal bool FrontVoice { get; init; }
+ /// Gets the root services, where the host's command handlers are found, or for none.
+ internal IServiceProvider? Services { get; init; }
+
+ ///
+ /// Gets the host's handler for one command, or when it registered none. It comes from the
+ /// root services: a call outlives the request that started it, so a scoped one would be used after its scope ended.
+ ///
+ internal IChannelCommandHandler? HandlerFor()
+ where TCommand : ChannelCommand
+ {
+ return Services?.GetService>();
+ }
+
/// Reads the host's shared parts from a started container.
internal static PhoneCallHost From(IServiceProvider services, ConversationProviderConfiguration? configuration)
{
@@ -42,7 +56,10 @@ internal static PhoneCallHost From(IServiceProvider services, ConversationProvid
boot.Hooks,
services.GetRequiredService(),
services.GetRequiredService().CreateLogger("AgentCore.Calls"),
- configuration?.AnswerSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : GatePoint.BeforeCall.Deadline);
+ configuration?.AnswerSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : GatePoint.BeforeCall.Deadline)
+ {
+ Services = boot.Services,
+ };
}
}
}
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs
index 0d9f8c4..c016e77 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/ILiveTransferLine.cs
@@ -1,3 +1,5 @@
+using AgentCore.Application.Conversation.Commands;
+
namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call
{
///
@@ -6,10 +8,10 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call
///
internal interface ILiveTransferLine
{
- /// The line.
+ /// The command, as the tool sent it.
/// Waits for the peer to close the AI leg, for a line whose only sign of success is that close.
/// The read loop's receive still open, or .
/// Cancels the hand-over.
- Task HandOverAsync(Uri target, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken);
+ Task HandOverAsync(TransferCommand transfer, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken);
}
}
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs
new file mode 100644
index 0000000..4e672d0
--- /dev/null
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveChannel.cs
@@ -0,0 +1,97 @@
+using AgentCore.Application.Conversation.Commands;
+using AgentCore.Application.Runtime.Session;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call
+{
+ ///
+ /// GPT-Live's channel. The webhook attaches it as soon as the call is admitted, before CallStarted, so a host
+ /// can send a command from that notice on. A transfer is the 's, once the call runs. Voice
+ /// context goes out as silent context at once, which in a probe never made the voice speak or cut its speech
+ /// (docs/probes/live-late-brief); context sent before session.started waits for it, as OpenAI's guide sends
+ /// nothing before it.
+ ///
+ internal sealed class LiveChannel(string callId, ILogger logger) : IConversationChannel
+ {
+ private readonly Lock _gate = new();
+
+ private readonly List _waiting = [];
+
+ private LiveTransfer? _transfer;
+
+ private Func? _tell;
+
+ private CancellationToken _stopping;
+
+ public ChannelCommandResult Send(ChannelCommand command)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ return command switch
+ {
+ TransferCommand transfer => Volatile.Read(ref _transfer)?.Send(transfer) ?? ChannelCommandResult.NotSupported,
+ AddVoiceContextCommand context => Add(context.Text),
+ _ => ChannelCommandResult.NotSupported,
+ };
+ }
+
+ /// Lets the call transfer. A transfer sent before this answers as not supported.
+ 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.
+ /// The call's token. Later context goes out under it too.
+ internal async ValueTask OpenAsync(Func tell, CancellationToken cancellationToken)
+ {
+ string[] waiting;
+ lock (_gate)
+ {
+ _stopping = cancellationToken;
+ _tell = tell;
+ waiting = [.. _waiting];
+ _waiting.Clear();
+ }
+
+ foreach (string text in waiting)
+ {
+ await tell(text, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private ChannelCommandResult Add(string text)
+ {
+ Func? tell;
+ lock (_gate)
+ {
+ tell = _tell;
+ if (tell is null)
+ {
+ _waiting.Add(text);
+ return ChannelCommandResult.Scheduled;
+ }
+ }
+
+ _ = TellAsync(tell, text);
+ return ChannelCommandResult.Scheduled;
+ }
+
+ private async Task TellAsync(Func tell, string text)
+ {
+ try
+ {
+ await tell(text, _stopping).ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ // The call ended and let go of its socket: there is no voice left to tell.
+ }
+ catch (OperationCanceledException) when (_stopping.IsCancellationRequested)
+ {
+ // The host is stopping; OpenAiLiveCalls ends the call itself.
+ }
+ catch (Exception fault) when (fault is not OutOfMemoryException)
+ {
+ OpenAiLiveLog.VoiceContextFailed(logger, callId, fault);
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs
index 36d2845..6e7fc55 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveHostTransfer.cs
@@ -1,22 +1,26 @@
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
using Microsoft.Extensions.Logging;
namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call
{
///
- /// The host's own transfer, through its . Its answer is the outcome, so nothing waits for
- /// the peer's close, and the AI leg is hung up from this side once the line took the call.
+ /// The host's own transfer, through its . Its answer is the
+ /// outcome, so nothing waits for the peer's close, and the AI leg is hung up from this side once the line took the call.
///
internal sealed class LiveHostTransfer(
- ICallTransfer host, string conversationId, IReadOnlyDictionary headers, ILogger logger, string callId) : ILiveTransferLine
+ IChannelCommandHandler host,
+ string conversationId,
+ IReadOnlyDictionary headers,
+ ILogger logger,
+ string callId) : ILiveTransferLine
{
- public async Task HandOverAsync(Uri target, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken)
+ public async Task HandOverAsync(TransferCommand transfer, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken)
{
CallTransferOutcome outcome;
try
{
- outcome = await host.TransferAsync(
- new CallTransfer { ConversationId = conversationId, Target = target, Headers = headers }, cancellationToken).ConfigureAwait(false);
+ outcome = await host.HandleAsync(
+ transfer, new ChannelCommandContext { ConversationId = conversationId, Headers = headers }, cancellationToken).ConfigureAwait(false);
}
catch (Exception fault) when (fault is not OperationCanceledException)
{
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs
index 2edc608..4d1c52e 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveReferTransfer.cs
@@ -1,3 +1,4 @@
+using AgentCore.Application.Conversation.Commands;
using Microsoft.Extensions.Logging;
namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call
@@ -9,12 +10,12 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call
///
internal sealed class LiveReferTransfer(Func> refer, TimeSpan wait, ILogger logger, string callId) : ILiveTransferLine
{
- public async Task HandOverAsync(Uri target, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken)
+ public async Task HandOverAsync(TransferCommand transfer, LivePeerClose peer, Task? receiving, CancellationToken cancellationToken)
{
bool sent;
try
{
- sent = await refer(target, cancellationToken).ConfigureAwait(false);
+ sent = await refer(transfer.Target, cancellationToken).ConfigureAwait(false);
}
catch (Exception fault) when (fault is not OperationCanceledException)
{
@@ -28,6 +29,7 @@ public async Task HandOverAsync(Uri target, LivePeerClose peer, Ta
}
(bool closed, receiving) = await peer.WaitAsync(wait, receiving, cancellationToken).ConfigureAwait(false);
+
return closed
? new LiveHandover(LiveHandoverOutcome.TakenAndClosed, null)
: new LiveHandover(LiveHandoverOutcome.NotTaken, receiving, "the call was still up when the transfer wait ran out");
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs
index 4197562..a105840 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/LiveTransfer.cs
@@ -1,43 +1,41 @@
-using AgentCore.Application.Conversation.Actions;
-using AgentCore.Application.Runtime.Session;
+using AgentCore.Application.Conversation.Commands;
namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call
{
///
- /// GPT-Live's side of . A request only records the action: the call's read loop hands the
+ /// GPT-Live's side of . A send only records the command: the call's read loop hands the
/// caller over through once the caller heard the answer, so the transfer never cuts a reply.
///
- internal sealed class LiveTransfer(ILiveTransferLine line, Func leaving, Action scheduled) : IConversationChannel
+ internal sealed class LiveTransfer(ILiveTransferLine line, Func leaving, Action scheduled)
{
- private TransferAction? _pending;
+ private TransferCommand? _pending;
internal ILiveTransferLine Line => line;
- internal Uri? Target => Volatile.Read(ref _pending)?.Target;
+ internal TransferCommand? Pending => Volatile.Read(ref _pending);
- internal string? IfFailed => Volatile.Read(ref _pending)?.IfFailed;
+ internal Uri? Target => Pending?.Target;
- public ConversationActionResult Request(ConversationAction action)
- {
- ArgumentNullException.ThrowIfNull(action);
- if (action is not TransferAction transfer)
- {
- return ConversationActionResult.NotSupported;
- }
+ internal string? IfFailed => Pending?.IfFailed;
+ internal ChannelCommandResult Send(TransferCommand transfer)
+ {
// A call that is leaving has no line left to hand over, and a second transfer would contradict the first,
// which may already be on the line.
if (leaving() || Interlocked.CompareExchange(ref _pending, transfer, null) is not null)
{
- return ConversationActionResult.Ending;
+ return ChannelCommandResult.Ending;
}
// A transfer asked outside a delegation has no answer of its own to leave after.
scheduled();
- return ConversationActionResult.Scheduled;
+ return ChannelCommandResult.Scheduled;
}
/// The line did not take the call and the call goes on, so the caller's next words start turns again.
- internal void Failed() => Volatile.Write(ref _pending, null);
+ internal void Failed()
+ {
+ Volatile.Write(ref _pending, null);
+ }
}
}
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs
index 77dda4e..121a220 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs
@@ -1,11 +1,10 @@
using System.Globalization;
using System.Text.Json.Nodes;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Hooks.Notices;
-using AgentCore.Application.Transcript;
using AgentCore.AspNetCore.Calls;
using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire;
using AgentCore.Domain.Audit;
-using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Call
@@ -43,9 +42,9 @@ internal sealed class OpenAiLiveCall : IDisposable
private readonly PhoneCall _call;
- private readonly ILiveSideband _sideband;
+ private readonly LiveChannel _channel;
- private readonly ILogger _logger;
+ private readonly ILiveSideband _sideband;
private readonly string? _greeting;
@@ -68,12 +67,16 @@ internal sealed class OpenAiLiveCall : IDisposable
private long _eventIds;
+ private bool _started;
+
///
- /// A greeting leaves GPT-Live silent until the caller speaks. A
+ /// is the call's channel, already attached to its conversation. A
+ /// greeting leaves GPT-Live silent until the caller speaks. A
/// transfer line leaves the call unable to transfer, so its conversation answers a transfer as not supported.
///
internal OpenAiLiveCall(
PhoneCall call,
+ LiveChannel channel,
ILiveSideband sideband,
Func> hangUp,
ILogger logger,
@@ -81,8 +84,9 @@ internal OpenAiLiveCall(
ILiveTransferLine? transferLine = null)
{
_call = call;
+ _channel = channel;
_sideband = sideband;
- _logger = logger;
+ Logger = logger;
_greeting = greeting;
_end = new LiveCallEnd(call.Host.Time, call.CallId, logger);
_hearing = new LiveHearing(call);
@@ -91,11 +95,15 @@ internal OpenAiLiveCall(
: new LiveTransfer(transferLine, () => _end.Finished || call.HasEnded, () => _ = TransferAfterAnswersAsync());
_answers = new LiveAnswers(call, _end, SendAsync, NextEventId, () => call.EngineEnded || _transfer?.Target is not null, logger);
_finish = new LiveCallFinish(call, _end, _answers, hangUp, logger);
+ if (_transfer is not null)
+ {
+ channel.Transfers(_transfer);
+ }
}
internal string CallId => _call.CallId;
- internal ILogger Logger => _logger;
+ internal ILogger Logger { get; }
/// Disposes the send lock and the end timers. calls this once every answer is done.
public void Dispose()
@@ -107,41 +115,32 @@ public void Dispose()
internal async Task RunAsync(CancellationToken cancellationToken)
{
Task? receiving = null;
+
Volatile.Write(ref _looping, 1);
+
_ = HangUpAfterAnyEngineEndAsync();
- if (_transfer is not null)
- {
- _call.Channel.Use(_transfer, _call.Session);
- }
try
{
- // GPT-Live knows no date of its own: on a real call it named the wrong weekday.
- if (_call.Session.Runner.ClockLine() is { } clock)
- {
- await SendAsync(OpenAiLiveWire.SessionFact(NextEventId(), clock), cancellationToken).ConfigureAwait(false);
- }
-
- if (_greeting is not null)
- {
- await SendAsync(OpenAiLiveWire.GreetFirst(NextEventId(), _greeting), cancellationToken).ConfigureAwait(false);
- }
-
while (true)
{
receiving ??= _sideband.ReceiveAsync(cancellationToken).AsTask();
+
_ = await Task.WhenAny(receiving, _end.Requested).ConfigureAwait(false);
+
if (_end.Requested.IsCompleted)
{
(ConversationEndReason reason, string cause) = await _end.Requested.ConfigureAwait(false);
+
await HearReceivedAsync(receiving).ConfigureAwait(false);
+
await _hearing.FlushAsync().ConfigureAwait(false);
// The quiet wait after the last answer ends in the transfer when one waits, in place of the hang-up.
- if (cause == AgentEndedCause && _transfer is { Target: { } target })
+ if (cause == AgentEndedCause && _transfer is { Pending: { } pending })
{
(receiving, bool goesOn) = await TransferAsync(
- _transfer, target, receiving is { IsCompleted: true } ? null : receiving, cancellationToken).ConfigureAwait(false);
+ _transfer, pending, receiving is { IsCompleted: true } ? null : receiving, cancellationToken).ConfigureAwait(false);
if (goesOn)
{
@@ -151,13 +150,17 @@ internal async Task RunAsync(CancellationToken cancellationToken)
return;
}
- OpenAiLiveLog.HungUpBecause(_logger, CallId, cause);
+ OpenAiLiveLog.HungUpBecause(Logger, CallId, cause);
+
await _finish.HangUpAndFinishAsync(reason, cause, cancellationToken).ConfigureAwait(false);
+
return;
}
string? json = await receiving.ConfigureAwait(false);
+
receiving = null;
+
if (json is null)
{
break;
@@ -170,6 +173,7 @@ internal async Task RunAsync(CancellationToken cancellationToken)
}
await _hearing.FlushAsync().ConfigureAwait(false);
+
await _finish.FinishAsync(ConversationEndReason.Faulted, SidebandClosedCause).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -178,15 +182,20 @@ internal async Task RunAsync(CancellationToken cancellationToken)
}
catch (Exception fault) when (fault is not OperationCanceledException)
{
- OpenAiLiveLog.SidebandFaulted(_logger, CallId, fault);
+ OpenAiLiveLog.SidebandFaulted(Logger, CallId, fault);
+
await _hearing.FlushAsync().ConfigureAwait(false);
+
await _finish.FinishAsync(ConversationEndReason.Faulted, SidebandFailedCause).ConfigureAwait(false);
}
finally
{
_ = _loopLeft.TrySetResult();
+
await _answers.WhenAnsweredAsync().ConfigureAwait(false);
+
Dispose();
+
await _sideband.DisposeAsync().ConfigureAwait(false);
// A receive left behind by an end ends with the socket; nothing reads its result.
@@ -202,6 +211,7 @@ internal async Task RunAsync(CancellationToken cancellationToken)
internal async Task EndAsync(ConversationEndReason reason, string cause, CancellationToken cancellationToken)
{
_end.Now(reason, cause);
+
if (Volatile.Read(ref _looping) == 1)
{
try
@@ -228,6 +238,7 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken
}
await _hearing.HearAsync(delta).ConfigureAwait(false);
+
return false;
case LiveEvent.Delegation delegation:
@@ -241,17 +252,28 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken
return false;
+ case LiveEvent.Started when !_started:
+ _started = true;
+
+ await OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ return false;
+
case LiveEvent.Appended { EventId: { } eventId }:
_end.Acked(eventId);
+
return false;
case LiveEvent.Closed end:
await _hearing.FlushAsync().ConfigureAwait(false);
+
await _finish.FinishAsync(ConversationEndReason.CallerHungUp, end.Reason ?? SidebandClosedCause).ConfigureAwait(false);
+
return true;
case LiveEvent.Failed failed:
- OpenAiLiveLog.LiveError(_logger, CallId, failed.Code ?? "unknown", failed.Message ?? string.Empty);
+ OpenAiLiveLog.LiveError(Logger, CallId, failed.Code ?? "unknown", failed.Message ?? string.Empty);
+
return false;
default:
@@ -259,13 +281,36 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken
}
}
+ private async ValueTask OpenAsync(CancellationToken cancellationToken)
+ {
+ // GPT-Live knows no date of its own: on a real call it named the wrong weekday.
+ if (_call.Session.Runner.ClockLine() is { } clock)
+ {
+ await SendAsync(OpenAiLiveWire.SessionFact(NextEventId(), clock), cancellationToken).ConfigureAwait(false);
+ }
+
+ await _channel.OpenAsync(TellAsync, cancellationToken).ConfigureAwait(false);
+
+ if (_greeting is not null)
+ {
+ await SendAsync(OpenAiLiveWire.GreetFirst(NextEventId(), _greeting), cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private ValueTask TellAsync(string context, CancellationToken cancellationToken)
+ {
+ return SendAsync(OpenAiLiveEvents.SessionThinking(context, NextEventId()), cancellationToken);
+ }
+
// An end from outside every delegation (an operator's, the host's) has no answer of its own to hang up after.
// Once the delegations in flight are answered, a call none of them set to end hangs up when GPT-Live's speech
// has been quiet for EndQuietWait. The session is read once: an unload during the call is not followed.
private async Task HangUpAfterAnyEngineEndAsync()
{
await _call.Session.Lifetime.Ending.WhenRequested.ConfigureAwait(false);
+
await _answers.WhenAnsweredAsync().ConfigureAwait(false);
+
_end.WhenQuiet();
}
@@ -274,15 +319,18 @@ private async Task HangUpAfterAnyEngineEndAsync()
private async Task TransferAfterAnswersAsync()
{
await _answers.WhenAnsweredAsync().ConfigureAwait(false);
+
_end.WhenQuiet();
}
// A message that arrived together with the end is still part of the call: its words are heard. A delegation in
// it is not answered, and the ledger flush after this closes the line it would have closed.
- private ValueTask HearReceivedAsync(Task? receiving) =>
- receiving is { IsCompletedSuccessfully: true, Result: { } json } && LiveEventReader.Read(json) is LiveEvent.Transcript delta
+ private ValueTask HearReceivedAsync(Task? receiving)
+ {
+ return receiving is { IsCompletedSuccessfully: true, Result: { } json } && LiveEventReader.Read(json) is LiveEvent.Transcript delta
? _hearing.HearAsync(delta)
: ValueTask.CompletedTask;
+ }
private async ValueTask SendAsync(JsonObject liveEvent, CancellationToken cancellationToken)
{
@@ -302,25 +350,29 @@ private async ValueTask SendAsync(JsonObject liveEvent, CancellationToken cancel
}
private async Task<(Task? Receiving, bool GoesOn)> TransferAsync(
- LiveTransfer transfer, Uri target, Task? receiving, CancellationToken cancellationToken)
+ LiveTransfer transfer, TransferCommand pending, Task? receiving, CancellationToken cancellationToken)
{
- OpenAiLiveLog.Transferring(_logger, CallId);
+ OpenAiLiveLog.Transferring(Logger, CallId);
+
LiveHandover handover = await transfer.Line
- .HandOverAsync(target, new LivePeerClose(_sideband, _hearing, _call.Host.Time), receiving, cancellationToken).ConfigureAwait(false);
+ .HandOverAsync(pending, new LivePeerClose(_sideband, _hearing, _call.Host.Time), receiving, cancellationToken).ConfigureAwait(false);
switch (handover.Outcome)
{
case LiveHandoverOutcome.Taken:
await _finish.HangUpAndFinishAsync(ConversationEndReason.TransferredToHuman, TransferredCause, cancellationToken).ConfigureAwait(false);
+
return (handover.Receiving, false);
case LiveHandoverOutcome.TakenAndClosed:
await _finish.FinishAsync(ConversationEndReason.TransferredToHuman, TransferredCause).ConfigureAwait(false);
+
return (null, false);
case LiveHandoverOutcome.NotTaken:
default:
bool goesOn = await FailedTransferAsync(transfer, handover.Why ?? "the line did not take the call", cancellationToken).ConfigureAwait(false);
+
return (handover.Receiving, goesOn);
}
}
@@ -331,15 +383,21 @@ private async Task FailedTransferAsync(LiveTransfer transfer, string why,
{
if (transfer.IfFailed is not { } words || _call.HasEnded)
{
- OpenAiLiveLog.TransferFailed(_logger, CallId, why);
+ OpenAiLiveLog.TransferFailed(Logger, CallId, why);
+
await _finish.HangUpAndFinishAsync(ConversationEndReason.Faulted, TransferFailedCause, cancellationToken).ConfigureAwait(false);
+
return false;
}
- OpenAiLiveLog.TransferFailedGoingOn(_logger, CallId, why);
+ OpenAiLiveLog.TransferFailedGoingOn(Logger, CallId, why);
+
transfer.Failed();
+
_end.Rearm();
+
await SendAsync(OpenAiLiveWire.SayNow(NextEventId(), words), cancellationToken).ConfigureAwait(false);
+
if (_call.EngineEnded)
{
_end.WhenQuiet();
@@ -348,6 +406,9 @@ private async Task FailedTransferAsync(LiveTransfer transfer, string why,
return true;
}
- private string NextEventId() => "ac" + Interlocked.Increment(ref _eventIds).ToString(CultureInfo.InvariantCulture);
+ private string NextEventId()
+ {
+ return "ac" + Interlocked.Increment(ref _eventIds).ToString(CultureInfo.InvariantCulture);
+ }
}
}
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs
index e454b11..fa9dda7 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveCalls.cs
@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
-using AgentCore.Application.Conversation.Actions;
using AgentCore.AspNetCore.DependencyInjection;
using AgentCore.AspNetCore.Vendors.OpenAiLive.Call;
using AgentCore.Domain.Audit;
@@ -26,12 +25,6 @@ internal sealed class OpenAiLiveCalls(IServiceProvider services) : IHostedServic
private readonly CancellationTokenSource _stopping = new();
- ///
- /// Gets the host's own transfer, or when it registered none. It comes from the root
- /// services: a call outlives the request that started it, so a scoped one would be used after its scope ended.
- ///
- internal ICallTransfer? HostTransfer => services.GetService();
-
private readonly Lock _gate = new();
private bool _stopped;
@@ -65,12 +58,21 @@ internal bool TryClaimWebhook(string webhookId, DateTimeOffset now)
return _webhooks.TryAdd(webhookId, now);
}
- internal bool TryClaimCall(string callId) => _claimed.TryAdd(callId, 0);
+ internal bool TryClaimCall(string callId)
+ {
+ return _claimed.TryAdd(callId, 0);
+ }
- internal void ReleaseCall(string callId) => _claimed.TryRemove(callId, out _);
+ internal void ReleaseCall(string callId)
+ {
+ _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 _);
+ internal void ReleaseWebhook(string webhookId)
+ {
+ _webhooks.TryRemove(webhookId, out _);
+ }
///
/// Runs a call to its end. A call that arrives after the host began to stop is hung up and ended with cause
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs
index 3e23c05..b75a311 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs
@@ -56,5 +56,8 @@ internal static partial class OpenAiLiveLog
[LoggerMessage(EventId = 327, Level = LogLevel.Warning, Message = "transferring the call {CallId} failed: {Why}. The caller is told, and the call goes on.")]
internal static partial void TransferFailedGoingOn(ILogger logger, string callId, string why);
+
+ [LoggerMessage(EventId = 328, Level = LogLevel.Warning, Message = "a fact for the voice of the call {CallId} could not be sent, so the voice goes on without it.")]
+ internal static partial void VoiceContextFailed(ILogger logger, string callId, Exception fault);
}
}
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs
index 4413285..c7aaa3e 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Webhook/OpenAiLiveWebhook.cs
@@ -1,4 +1,5 @@
using AgentCore.Application.Configuration.Schema;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Hooks.Gates;
using AgentCore.Application.Hooks.Layers;
using AgentCore.AspNetCore.Calls;
@@ -49,6 +50,7 @@ internal async Task HandleAsync(HttpContext http)
string? webhookId = Header(http, "webhook-id");
http.Response.StatusCode = StatusCodes.Status200OK;
+
if (!calls.TryClaimWebhook(webhookId!, time.GetUtcNow())
|| OpenAiLiveWire.ReadIncomingCall(body) is not { } incoming
|| !calls.TryClaimCall(incoming.CallId))
@@ -95,6 +97,10 @@ private async Task AnswerAsync(HttpContext http, LiveIncomingCall incoming
return false;
}
+ // Attached before the call starts, so a command a hook sends from CallStarted on reaches the call.
+ LiveChannel channel = new(incoming.CallId, logger);
+ call.Channel.Use(channel, call.Session);
+
if (!await AcceptAndStartAsync(call, incoming.CallId, logger, stopping).ConfigureAwait(false))
{
return false;
@@ -108,23 +114,29 @@ private async Task AnswerAsync(HttpContext http, LiveIncomingCall incoming
catch (Exception fault) when (fault is not OutOfMemoryException)
{
OpenAiLiveLog.AttachFailed(logger, incoming.CallId, fault);
+
await HangUpAsync(incoming.CallId, logger).ConfigureAwait(false);
+
await call.EndAsync(ConversationEndReason.Faulted, OpenAiLiveCall.AttachFailedCause).ConfigureAwait(false);
+
await call.CloseAsync().ConfigureAwait(false);
+
return false;
}
- ILiveTransferLine transferLine = calls.HostTransfer is { } hostTransfer
+ ILiveTransferLine transferLine = host.HandlerFor() is { } hostTransfer
? new LiveHostTransfer(hostTransfer, call.ConversationId, incoming.Headers, logger, incoming.CallId)
: new LiveReferTransfer((target, token) => control.ReferAsync(incoming.CallId, target, token), settings.TransferWait, logger, incoming.CallId);
calls.Run(new OpenAiLiveCall(
call,
+ channel,
sideband,
_ => control.HangupQuietlyAsync(incoming.CallId),
logger,
settings.Greeting,
transferLine));
+
return true;
}
@@ -141,7 +153,9 @@ private async Task AcceptAndStartAsync(PhoneCall call, string callId, ILog
}
await call.StartAsync(stopping).ConfigureAwait(false);
+
started = true;
+
return true;
}
catch (Exception)
@@ -149,6 +163,7 @@ private async Task AcceptAndStartAsync(PhoneCall call, string callId, ILog
// A thrown accept may still have landed, and a failed start follows an accept that did: either way
// GPT-Live would be left with a call and no sideband.
await HangUpAsync(callId, logger).ConfigureAwait(false);
+
throw;
}
finally
@@ -166,12 +181,14 @@ private async Task AcceptedAsync(PhoneCall call, string callId, ILogger lo
callId,
OpenAiLiveWire.AcceptBody(settings.Model, settings.Voice, LiveInstructions.Build(settings.Instructions, call.Brief)),
stopping).ConfigureAwait(false);
+
if (outcome == AcceptOutcome.Accepted)
{
return true;
}
OpenAiLiveLog.AcceptRefused(logger, callId, outcome);
+
if (outcome == AcceptOutcome.Failed && !await control.RejectQuietlyAsync(callId, CallRefusal.Unavailable).ConfigureAwait(false))
{
OpenAiLiveLog.RejectFailed(logger, callId);
@@ -188,18 +205,22 @@ private async Task HangUpAsync(string callId, ILogger logger)
}
}
- private static string? Header(HttpContext http, string name) =>
- http.Request.Headers.TryGetValue(name, out StringValues value) ? value.ToString() : null;
+ private static string? Header(HttpContext http, string name)
+ {
+ return http.Request.Headers.TryGetValue(name, out StringValues value) ? value.ToString() : null;
+ }
/// Leaves the body buffered, because reads it again.
internal async ValueTask IsOpenAiAsync(HttpContext http)
{
OpenAiLiveCredentials keys = await credentials.ConfigureAwait(false);
+
http.Request.EnableBuffering();
byte[]? body = await ReadBodyAsync(http.Request, http.RequestAborted).ConfigureAwait(false);
http.Request.Body.Position = 0;
TimeProvider time = http.RequestServices.GetRequiredService();
+
if (body is not null
&& StandardWebhookSignature.Verify(keys.WebhookKey, Header(http, "webhook-id"), Header(http, "webhook-timestamp"), body, Header(http, "webhook-signature"), time.GetUtcNow()))
{
@@ -207,6 +228,7 @@ internal async ValueTask IsOpenAiAsync(HttpContext http)
}
OpenAiLiveLog.SignatureRefused(http.RequestServices.GetRequiredService().CreateLogger("AgentCore.OpenAiLive"));
+
return false;
}
@@ -215,6 +237,7 @@ internal async ValueTask IsOpenAiAsync(HttpContext http)
using MemoryStream body = new();
byte[] buffer = new byte[8192];
int read;
+
while ((read = await request.Body.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
if (body.Length + read > MaxBodyBytes)
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs
index ca4b7ac..cde9631 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEvent.cs
@@ -9,6 +9,9 @@ private protected LiveEvent()
{
}
+ /// The session is ready for session-wide instructions.
+ internal sealed record Started : LiveEvent;
+
/// A piece of what one side said. Times are milliseconds from the session's start; start_ms of the last word, not its end.
internal sealed record Transcript(Speaker Speaker, string Delta, int StartMs, int EndMs) : LiveEvent;
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs
index 5da5b24..c87d162 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/LiveEventReader.cs
@@ -24,6 +24,7 @@ internal static LiveEvent Read(string json)
return type switch
{
+ OpenAiLiveEvents.Started => new LiveEvent.Started(),
OpenAiLiveEvents.InputTranscriptDelta => Transcript(root, Speaker.Caller, type),
OpenAiLiveEvents.OutputTranscriptDelta => Transcript(root, Speaker.Agent, type),
OpenAiLiveEvents.DelegationCreated => root.TryGetProperty("delegation", out JsonElement delegation) && Text(delegation, "id") is { Length: > 0 } id
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs
index 6059dd0..a559707 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveEvents.cs
@@ -8,6 +8,8 @@ namespace AgentCore.AspNetCore.Vendors.OpenAiLive.Wire
///
internal static class OpenAiLiveEvents
{
+ internal const string Started = "session.started";
+
internal const string InputTranscriptDelta = "session.input_transcript.delta";
internal const string OutputTranscriptDelta = "session.output_transcript.delta";
@@ -23,17 +25,35 @@ internal static class OpenAiLiveEvents
internal const string Closed = "session.closed";
/// Silent context for the open delegation.
- internal static JsonObject Thinking(string delegationId, string content, string eventId) => Append(ThinkingAppend, delegationId, content, eventId);
+ internal static JsonObject Thinking(string delegationId, string content, string eventId)
+ {
+ return Append(ThinkingAppend, delegationId, content, eventId);
+ }
+
+ ///
+ /// Silent context for the whole session, under no delegation. In a probe it never made GPT-Live speak or cut its
+ /// speech; session.instructions.append cut the greeting (docs/probes/live-late-brief).
+ ///
+ internal static JsonObject SessionThinking(string content, string eventId)
+ {
+ return Append(ThinkingAppend, delegationId: null, content, eventId);
+ }
/// Words GPT-Live speaks for the open delegation, lightly paraphrased. At most 500 tokens.
- internal static JsonObject Commentary(string delegationId, string content, string eventId) => Append(CommentaryAppend, delegationId, content, eventId);
+ internal static JsonObject Commentary(string delegationId, string content, string eventId)
+ {
+ return Append(CommentaryAppend, delegationId, content, eventId);
+ }
- private static JsonObject Append(string type, string delegationId, string content, string eventId) => new()
+ private static JsonObject Append(string type, string? delegationId, string content, string eventId)
{
- ["type"] = type,
- ["delegation_id"] = delegationId,
- ["content"] = content,
- ["event_id"] = eventId,
- };
+ return new()
+ {
+ ["type"] = type,
+ ["delegation_id"] = delegationId,
+ ["content"] = content,
+ ["event_id"] = eventId,
+ };
+ }
}
}
diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs
index 2c8c29a..9a732bf 100644
--- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs
+++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Wire/OpenAiLiveWire.cs
@@ -70,12 +70,20 @@ internal static Uri AttachUri(Uri apiBase, string callId)
};
///
- /// The event that makes GPT-Live greet the caller instead of waiting for the caller's voice: instructions with no
- /// delegation, from OpenAI's live-conversations guide. A real SIP call without it stayed silent until the caller spoke.
+ /// The event that makes GPT-Live greet the caller instead of waiting for the caller's voice: speakable commentary
+ /// with no delegation. A real SIP call without it stayed silent until the caller spoke. OpenAI's live-conversations
+ /// 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) => SessionFact(eventId, greeting);
+ internal static JsonObject GreetFirst(string eventId, string greeting) => new()
+ {
+ ["type"] = OpenAiLiveEvents.CommentaryAppend,
+ ["event_id"] = eventId,
+ ["delegation_id"] = null,
+ ["content"] = greeting,
+ };
- /// Words GPT-Live says at once, outside every delegation: the event that makes it greet first.
+ /// Words GPT-Live says at once, outside every delegation.
internal static JsonObject SayNow(string eventId, string words) => SessionFact(eventId, words);
///
diff --git a/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs b/tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs
similarity index 56%
rename from tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs
rename to tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs
index 23b18e8..5dca130 100644
--- a/tests/AgentCore.Application.Tests/Conversation/ConversationActionsTests.cs
+++ b/tests/AgentCore.Application.Tests/Conversation/ChannelCommandsTests.cs
@@ -1,4 +1,4 @@
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Hooks.Notices;
using AgentCore.Application.Runtime.Session;
using AgentCore.Application.Tests.Fakes;
@@ -10,34 +10,34 @@
namespace AgentCore.Application.Tests.Conversation
{
- public sealed class ConversationActionsTests
+ public sealed class ChannelCommandsTests
{
private static readonly Uri Staff = new("tel:+15550002222");
[Fact]
- public async Task AnEndActionEndsTheConversationWithItsReason()
+ public async Task AnEndCommandEndsTheConversationWithItsReason()
{
RecordingHook hook = new();
using ScriptedChatClient reply = new("hello");
ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply, [hook]);
- Assert.Equal(ConversationActionResult.Scheduled, session.Request(new EndConversationAction(ConversationEndReason.TransferredToHuman)));
+ Assert.Equal(ChannelCommandResult.Scheduled, session.Send(new EndCommand(ConversationEndReason.TransferredToHuman)));
await session.FlushNoticesAsync();
Assert.Equal(ConversationEndReason.TransferredToHuman, Assert.Single(hook.Of()).Reason);
}
[Fact]
- public void AnEndingConversationTakesNoActionAndNeverAsksTheChannel()
+ public void AnEndingConversationTakesNoCommandAndNeverAsksTheChannel()
{
using ScriptedChatClient reply = new("hello");
ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply);
- RecordingChannel channel = new(ConversationActionResult.Scheduled);
- session.Actions.Attach(channel);
+ RecordingChannel channel = new(ChannelCommandResult.Scheduled);
+ session.Commands.Attach(channel);
_ = session.EndConversation(ConversationEndReason.CallerHungUp);
- Assert.Equal(ConversationActionResult.Ending, session.Request(new EndConversationAction(ConversationEndReason.AgentCompleted)));
- Assert.Equal(ConversationActionResult.Ending, session.Request(new TransferAction(Staff)));
+ Assert.Equal(ChannelCommandResult.Ending, session.Send(new EndCommand(ConversationEndReason.AgentCompleted)));
+ Assert.Equal(ChannelCommandResult.Ending, session.Send(new TransferCommand(Staff)));
Assert.Empty(channel.Seen);
}
@@ -47,7 +47,7 @@ public void ATransferWithNoChannelIsNotSupportedAndLeavesTheConversationOpen()
using ScriptedChatClient reply = new("hello");
ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply);
- Assert.Equal(ConversationActionResult.NotSupported, session.Request(new TransferAction(Staff)));
+ Assert.Equal(ChannelCommandResult.NotSupported, session.Send(new TransferCommand(Staff)));
Assert.False(session.IsComplete);
}
@@ -56,26 +56,26 @@ public void TheAttachedChannelTakesATransfer()
{
using ScriptedChatClient reply = new("hello");
ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply);
- RecordingChannel channel = new(ConversationActionResult.Scheduled);
- TransferAction transfer = new(Staff);
+ RecordingChannel channel = new(ChannelCommandResult.Scheduled);
+ TransferCommand transfer = new(Staff);
- session.Actions.Attach(channel);
- Assert.Equal(ConversationActionResult.Scheduled, session.Request(transfer));
+ session.Commands.Attach(channel);
+ Assert.Equal(ChannelCommandResult.Scheduled, session.Send(transfer));
Assert.Same(transfer, Assert.Single(channel.Seen));
}
- // A newer call that takes the conversation over carries its actions from then on.
+ // A newer call that takes the conversation over carries its commands from then on.
[Fact]
public void ANewerChannelReplacesTheOlderOne()
{
using ScriptedChatClient reply = new("hello");
ConversationSession session = HookSessions.Create(HookSessions.OneAgentYaml, reply);
- RecordingChannel older = new(ConversationActionResult.Scheduled);
- RecordingChannel newer = new(ConversationActionResult.Scheduled);
- session.Actions.Attach(older);
- session.Actions.Attach(newer);
+ RecordingChannel older = new(ChannelCommandResult.Scheduled);
+ RecordingChannel newer = new(ChannelCommandResult.Scheduled);
+ session.Commands.Attach(older);
+ session.Commands.Attach(newer);
- Assert.Equal(ConversationActionResult.Scheduled, session.Request(new TransferAction(Staff)));
+ Assert.Equal(ChannelCommandResult.Scheduled, session.Send(new TransferCommand(Staff)));
Assert.Single(newer.Seen);
Assert.Empty(older.Seen);
}
@@ -85,7 +85,7 @@ public void AScopeBuiltOutsideATurnCanDoNothing()
{
ToolCallScope scope = new("c-1", 0, string.Empty);
- Assert.Equal(ConversationActionResult.NotSupported, scope.Conversation.Request(new EndConversationAction(ConversationEndReason.AgentCompleted)));
+ Assert.Equal(ChannelCommandResult.NotSupported, scope.Channel.Send(new EndCommand(ConversationEndReason.AgentCompleted)));
}
[Theory]
@@ -94,7 +94,7 @@ public void AScopeBuiltOutsideATurnCanDoNothing()
[InlineData("sips:agent@example.com")]
public void ATransferTakesTelSipAndSipsTargets(string target)
{
- Assert.Equal(new Uri(target), new TransferAction(new Uri(target)).Target);
+ Assert.Equal(new Uri(target), new TransferCommand(new Uri(target)).Target);
}
[Theory]
@@ -102,16 +102,23 @@ public void ATransferTakesTelSipAndSipsTargets(string target)
[InlineData("mailto:staff@example.com")]
public void ATransferRefusesAnyOtherTarget(string target)
{
- _ = Assert.Throws(() => new TransferAction(new Uri(target)));
+ _ = Assert.Throws(() => new TransferCommand(new Uri(target)));
}
- private sealed class RecordingChannel(ConversationActionResult answer) : IConversationChannel
+ // GPT-Live takes at most 500 tokens in one update.
+ [Fact]
+ public void AVoiceFactIsCutToItsFirstThousandCharacters()
+ {
+ Assert.Equal(new string('a', 1000), new AddVoiceContextCommand(new string('a', 1000) + "b").Text);
+ }
+
+ private sealed class RecordingChannel(ChannelCommandResult answer) : IConversationChannel
{
- public List Seen { get; } = [];
+ public List Seen { get; } = [];
- public ConversationActionResult Request(ConversationAction action)
+ public ChannelCommandResult Send(ChannelCommand command)
{
- Seen.Add(action);
+ Seen.Add(command);
return answer;
}
}
diff --git a/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs b/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs
index 98725f0..340b0b0 100644
--- a/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs
+++ b/tests/AgentCore.Application.Tests/Tools/ToolCallScopeTests.cs
@@ -1,6 +1,6 @@
using System.Runtime.CompilerServices;
using AgentCore.Application.Configuration.Compilation;
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
using AgentCore.Application.Configuration.Validation;
@@ -63,16 +63,16 @@ public async Task ABoundTool_ReceivesTheRunningConversationsScope()
[Fact]
public async Task ABoundToolAsksItsOwnConversationForAnActionThroughTheScope()
{
- List answers = [];
+ List answers = [];
ToolBindingRegistry bindings = new();
_ = bindings.Register(
"RequestHuman",
- (string reason, ToolCallScope scope) => answers.Add(scope.Conversation.Request(new EndConversationAction(ConversationEndReason.TransferredToHuman))));
+ (string reason, ToolCallScope scope) => answers.Add(scope.Channel.Send(new EndCommand(ConversationEndReason.TransferredToHuman))));
ConversationSession session = await CreateAsync(bindings, "conversation-scope-2");
_ = await session.RunTurnAsync("I need a person", TestContext.Current.CancellationToken);
- Assert.Equal(ConversationActionResult.Scheduled, Assert.Single(answers));
+ Assert.Equal(ChannelCommandResult.Scheduled, Assert.Single(answers));
Assert.True(session.IsComplete);
}
diff --git a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs
index 426d566..3676e89 100644
--- a/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs
+++ b/tests/AgentCore.AspNetCore.Tests/Calls/PhoneCallChannelTests.cs
@@ -1,4 +1,4 @@
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Runtime.Session;
using AgentCore.AspNetCore.Calls;
using AgentCore.AspNetCore.Tests.Fakes;
@@ -8,11 +8,11 @@ namespace AgentCore.AspNetCore.Tests.Calls
{
public sealed class PhoneCallChannelTests
{
- private static readonly TransferAction Transfer = new(new Uri("tel:+15550002222"));
+ private static readonly TransferCommand Transfer = new(new Uri("tel:+15550002222"));
private static CancellationToken Ct => TestContext.Current.CancellationToken;
- // The tools of the next ask run on the session reopened after an idle unload, so the vendor's actions must be there.
+ // The tools of the next ask run on the session reopened after an idle unload, so the vendor's channel must be there.
[Fact]
public async Task TheChannelFollowsTheCallToASessionReopenedAfterAnUnload()
{
@@ -20,17 +20,17 @@ public async Task TheChannelFollowsTheCallToASessionReopenedAfterAnUnload()
PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!;
await call.StartAsync(Ct);
call.Channel.Use(new AcceptingChannel(), call.Session);
- Assert.Equal(ConversationActionResult.Scheduled, call.Session.Request(Transfer));
+ Assert.Equal(ChannelCommandResult.Scheduled, call.Session.Send(Transfer));
await harness.Sessions.CloseAsync("main", "call-7", Ct);
ConversationSession reopened = await call.LiveSessionAsync(Ct);
- Assert.Equal(ConversationActionResult.Scheduled, reopened.Request(Transfer));
+ Assert.Equal(ChannelCommandResult.Scheduled, reopened.Send(Transfer));
}
private sealed class AcceptingChannel : IConversationChannel
{
- public ConversationActionResult Request(ConversationAction action) => ConversationActionResult.Scheduled;
+ public ChannelCommandResult Send(ChannelCommand command) => ChannelCommandResult.Scheduled;
}
}
}
diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs
index 2fc8cee..30286e7 100644
--- a/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs
+++ b/tests/AgentCore.AspNetCore.Tests/Fakes/OpenAiLiveHost.cs
@@ -78,13 +78,15 @@ private OpenAiLiveHost(WebApplication app, HttpClient client, AgentCoreHttpClien
/// Starts the host.
/// Names the fault the sideband attach of a call throws, or for none.
/// Anything else the test binds on the options, such as a conversation store.
+ /// Holds each sideband attach until it completes, or to attach at once.
public static async Task StartAsync(
IChatClient model,
IReadOnlyList hooks,
MapSecretResolver? secrets = null,
string yaml = LiveYaml,
Func? attachFault = null,
- Action? configure = null)
+ Action? configure = null,
+ Task? attachHeldUntil = null)
{
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder();
_ = builder.WebHost.UseUrls("http://127.0.0.1:0");
@@ -97,21 +99,28 @@ public static async Task StartAsync(
OpenAiLiveConversationAdapter adapter = new(
pipeline,
secrets ?? new MapSecretResolver().With("openai-api-key", ApiKey).With("openai-webhook-secret", WebhookSecret),
- (attach, token) =>
+ async (attach, token) =>
{
if (attachFault?.Invoke(attach) is { } fault)
{
throw fault;
}
+ if (attachHeldUntil is not null)
+ {
+ await attachHeldUntil.WaitAsync(token);
+ }
+
+ // A real sideband opens with session.started (docs/probes/live-transfer-t1/t1-none.log, 0 ms after attach).
FakeSideband sideband = new();
+ sideband.Push([RunningLiveCall.Started]);
lock (attached)
{
attached.Add(sideband);
}
_ = host?.FirstAttach.TrySetResult(sideband);
- return ValueTask.FromResult(sideband);
+ return (ILiveSideband)sideband;
},
new Uri("https://api.openai.test/"));
diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs
index 1f165f9..1f0cd3b 100644
--- a/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs
+++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RunningLiveCall.cs
@@ -1,5 +1,5 @@
using AgentCore.Application.Configuration.Schema;
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Hooks;
using AgentCore.AspNetCore.Calls;
using AgentCore.AspNetCore.Vendors.OpenAiLive;
@@ -22,6 +22,9 @@ internal sealed class RunningLiveCall : IAsyncDisposable
public const string Greeting = "Greet the caller, then pause and listen.";
+ /// The first event every recorded GPT-Live session sent.
+ public const string Started = """{"type":"session.started"}""";
+
private RunningLiveCall(PhoneCallHarness harness, FakeTimeProvider time, PhoneCall call, FakeSideband sideband)
{
Harness = harness;
@@ -48,13 +51,16 @@ private RunningLiveCall(PhoneCallHarness harness, FakeTimeProvider time, PhoneCa
public TaskCompletionSource Referred { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
///
- /// Starts the call. With and unset the call cannot
- /// transfer. transfers by refer, and each refer answers it;
- /// transfers through a host's own , which answers it.
+ /// Starts the call, which GPT-Live opens with unless is false.
+ /// With and unset the call cannot transfer.
+ /// transfers by refer, and each refer answers it;
+ /// transfers through a host's own , which answers it.
+ /// The call's channel is attached before the loop runs, as the webhook attaches it.
///
public static async Task StartAsync(
IChatClient model, IReadOnlyList hooks, string yaml = PhoneCallHarness.OneEntryYaml, ILogger? logger = null, FakeSideband? sideband = null,
- string? greeting = Greeting, Func? tools = null, bool? refers = null, CallTransferOutcome? hostAnswers = null)
+ string? greeting = Greeting, Func? tools = null, bool? refers = null, CallTransferOutcome? hostAnswers = null,
+ bool started = true)
{
CancellationToken ct = TestContext.Current.CancellationToken;
FakeTimeProvider time = new(Noon) { Zone = TimeZoneInfo.Utc };
@@ -69,7 +75,14 @@ public static async Task StartAsync(
(null, { } answer) => new LiveReferTransfer((target, _) => running.ReferAsync(target, answer), OpenAiLiveSettings.DefaultTransferWait, logger, call.CallId),
_ => null,
};
- OpenAiLiveCall live = new(call, running.Sideband, running.HangUpAsync, logger, greeting, line);
+ LiveChannel channel = new(call.CallId, logger);
+ call.Channel.Use(channel, call.Session);
+ OpenAiLiveCall live = new(call, channel, running.Sideband, running.HangUpAsync, logger, greeting, line);
+ if (started)
+ {
+ running.Sideband.Push([Started]);
+ }
+
running.Loop = live.RunAsync(ct);
return running;
}
@@ -93,9 +106,9 @@ private Task HangUpAsync(CancellationToken cancellationToken)
return Task.FromResult(true);
}
- private sealed class AnsweringTransfer(RunningLiveCall running, CallTransferOutcome outcome) : ICallTransfer
+ private sealed class AnsweringTransfer(RunningLiveCall running, CallTransferOutcome outcome) : IChannelCommandHandler
{
- public Task TransferAsync(CallTransfer transfer, CancellationToken cancellationToken)
+ public Task HandleAsync(TransferCommand transfer, ChannelCommandContext context, CancellationToken cancellationToken)
{
_ = running.Referred.TrySetResult(transfer.Target);
return Task.FromResult(outcome);
diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs
index e038e42..cf782b1 100644
--- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs
+++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/LiveSent.cs
@@ -30,11 +30,14 @@ internal static class LiveSent
- { id: done, agent: only, terminal: true }
""";
- // Every call opens with the session instructions (see TheCallTellsGptLiveToGreetTheCallerFirst).
+ // 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");
+ running.Sideband.Sent.Where(sent => (string?)sent["type"] != "session.instructions.append" && !IsGreeting(sent));
- internal static bool IsCommentary(JsonObject sent) => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend;
+ // 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;
+
+ private static bool IsGreeting(JsonObject sent) => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend && sent["delegation_id"] is null;
internal static string? TypeOf(string json) => (string?)JsonNode.Parse(json)!["type"];
diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs
index 74b24c1..69502a2 100644
--- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs
+++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallTests.cs
@@ -145,22 +145,40 @@ public async Task ASidebandThatFailsMidLineStillRaisesTheOpenLine()
Assert.Equal((ConversationEndReason.Faulted, "sideband_failed"), (ended.Reason, ended.Call!.Cause));
}
- // GPT-Live waits for the caller's voice unless it is told to speak first (OpenAI's live-conversations guide):
- // the configured greeting as a session.instructions.append with no delegation, before anything else.
+ // GPT-Live waits for the caller's voice unless it is told to speak first. The configured greeting goes out before
+ // anything else as speakable commentary with no delegation: sent as session instructions, GPT-Live spoke it in
+ // only 5 of 10 probe sessions (2026-10-06); as commentary, in 8 of 8, within about a second.
[Fact(Timeout = 30_000)]
public async Task TheCallTellsGptLiveToGreetTheCallerFirst()
{
await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], EndsAfterTheFirstAskYaml);
- JsonObject greet = await running.Sideband.WaitForSentAsync(sent => (string?)sent["type"] == "session.instructions.append");
+ JsonObject greet = await running.Sideband.WaitForSentAsync(sent => (string?)sent["content"] == RunningLiveCall.Greeting);
Assert.Same(greet, running.Sideband.Sent[0]);
+ Assert.Equal(OpenAiLiveEvents.CommentaryAppend, (string?)greet["type"]);
Assert.True(greet.ContainsKey("delegation_id"));
Assert.Null(greet["delegation_id"]);
Assert.Equal(RunningLiveCall.Greeting, (string?)greet["content"]);
Assert.False(string.IsNullOrWhiteSpace((string?)greet["event_id"]));
}
+ // OpenAI's live-conversations guide: "send greeting instructions after session.started". Instructions sent
+ // before it may land in a session that is not ready; a real call reported context_injection_incomplete.
+ [Fact(Timeout = 30_000)]
+ public async Task TheGreetingWaitsForTheSessionToStart()
+ {
+ await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], started: false);
+ await running.Sideband.WaitForDrainAsync();
+ bool sentBeforeTheStart = running.Sideband.Sent.Count > 0;
+
+ running.Sideband.Push([RunningLiveCall.Started, RunningLiveCall.Started]);
+ await running.Sideband.WaitForDrainAsync();
+
+ Assert.False(sentBeforeTheStart);
+ 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.
[Fact(Timeout = 30_000)]
public async Task ACallThatLostItsConversationIsHungUpWithNoCommentary()
diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs
index 0ee4e02..4b30581 100644
--- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs
+++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallsTests.cs
@@ -82,7 +82,7 @@ private static async Task StartedCallAsync(
{
PhoneCall call = (await PhoneCall.AdmitAsync(harness.Host, PhoneCallHarness.Offer(), Ct)).Call!;
await call.StartAsync(Ct);
- return new OpenAiLiveCall(call, sideband, hangUp, logger ?? NullLogger.Instance);
+ return new OpenAiLiveCall(call, new LiveChannel(call.CallId, NullLogger.Instance), sideband, hangUp, logger ?? NullLogger.Instance);
}
}
}
diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs
index 131a594..23e70fc 100644
--- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs
+++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveEndToEndTests.cs
@@ -26,7 +26,7 @@ public async Task TheRecordedP1CallRunsThroughTheRealRoute()
int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated);
sideband.Push(log.Take(delegation + 1));
- JsonObject commentary = await sideband.WaitForSentAsync(sent => (string?)sent["type"] == OpenAiLiveEvents.CommentaryAppend);
+ JsonObject commentary = await sideband.WaitForSentAsync(LiveSent.IsCommentary);
sideband.Push(log.Skip(delegation + 1));
ConversationEnded ended = await hook.WaitForAsync();
diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs
index 402acb6..27a6077 100644
--- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs
+++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveTransferTests.cs
@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
using System.Text.Json.Nodes;
using AgentCore.Application.Configuration.Schema;
-using AgentCore.Application.Conversation.Actions;
+using AgentCore.Application.Conversation.Commands;
using AgentCore.Application.Hooks.Notices;
using AgentCore.Application.Tools;
using AgentCore.Application.Tools.Binding;
@@ -34,7 +34,7 @@ public sealed class OpenAiLiveTransferTests
public async Task TheReferWaitsUntilTheAnswerWasHeardAndACloseEndsTheCallAsTransferred()
{
RecordingHook hook = new();
- ConcurrentQueue answers = new();
+ ConcurrentQueue answers = new();
await using RunningLiveCall running = await RunningLiveCall.StartAsync(
new GatedToolCallingChatClient(), [hook], tools: TransferTool(answers), refers: true);
@@ -51,7 +51,7 @@ public async Task TheReferWaitsUntilTheAnswerWasHeardAndACloseEndsTheCallAsTrans
await running.Loop;
ConversationEnded ended = await hook.WaitForAsync();
- Assert.Equal([ConversationActionResult.Scheduled], answers);
+ Assert.Equal([ChannelCommandResult.Scheduled], answers);
Assert.False(referredBeforeSpeechStarted);
Assert.Equal(Staff, target);
Assert.False(running.HungUp.Task.IsCompleted);
@@ -153,7 +153,7 @@ public async Task ATransferTheHostCouldNotCarryOutSaysTheHostsWordsAndTheCallGoe
[Fact(Timeout = 30_000)]
public async Task ACallWithNoReferAnswersATransferAsNotSupportedAndStaysUp()
{
- ConcurrentQueue answers = new();
+ ConcurrentQueue answers = new();
await using RunningLiveCall running = await RunningLiveCall.StartAsync(new GatedToolCallingChatClient(), [], tools: TransferTool(answers));
running.Sideband.Push(Ask("item_1", "Can I talk to a person?", 1000));
@@ -161,16 +161,16 @@ public async Task ACallWithNoReferAnswersATransferAsNotSupportedAndStaysUp()
await running.Sideband.WaitForDrainAsync();
running.Time.Advance(OpenAiLiveCall.EndQuietWait);
- Assert.Equal([ConversationActionResult.NotSupported], answers);
+ Assert.Equal([ChannelCommandResult.NotSupported], answers);
Assert.False(running.Call.HasEnded);
}
// 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) =>
+ private static Func TransferTool(ConcurrentQueue answers, string? ifFailed = null) =>
tool => AIFunctionFactory.Create(
(ToolCallScope scope) =>
{
- ConversationActionResult answer = scope.Conversation.Request(new TransferAction(Staff) { IfFailed = ifFailed });
+ ChannelCommandResult answer = scope.Channel.Send(new TransferCommand(Staff) { IfFailed = ifFailed });
answers.Enqueue(answer);
return answer.ToString();
},
diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs
new file mode 100644
index 0000000..3ee62d8
--- /dev/null
+++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveVoiceContextTests.cs
@@ -0,0 +1,93 @@
+using System.Text.Json.Nodes;
+using AgentCore.Application.Conversation.Commands;
+using AgentCore.Application.Hooks;
+using AgentCore.Application.Hooks.Gates;
+using AgentCore.Application.Hooks.Notices;
+using AgentCore.Application.Ports;
+using AgentCore.Application.Runtime.Session;
+using AgentCore.AspNetCore.Tests.Fakes;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace AgentCore.AspNetCore.Tests.Vendors.OpenAiLive
+{
+ ///
+ /// A host can tell the voice a fact it looked up after the call was answered. OpenAI's live guide sends one as
+ /// session.thinking.append with delegation_id: null: "factual context without asking the model to say
+ /// it immediately".
+ ///
+ public sealed class OpenAiLiveVoiceContextTests
+ {
+ private const string Fact = "A staff note from this caller's earlier chat, for context only.\n\"Ask first.\"";
+
+ [Fact(Timeout = 30_000)]
+ public async Task AFactForTheVoiceGoesOutAtOnceAsSilentSessionContext()
+ {
+ await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), []);
+ _ = await running.Sideband.WaitForSentAsync(IsGreeting);
+
+ ChannelCommandResult result = running.Call.Session.Send(new AddVoiceContextCommand(Fact));
+ JsonObject fact = await running.Sideband.WaitForSentAsync(IsThinking);
+
+ Assert.Equal(ChannelCommandResult.Scheduled, result);
+ Assert.Null(fact["delegation_id"]);
+ Assert.Equal(Fact, (string?)fact["content"]);
+ }
+
+ // The guide sends nothing before session.started, and the voice should know the fact before it greets.
+ [Fact(Timeout = 30_000)]
+ public async Task AFactSentBeforeTheSessionStartedWaitsForItAndGoesBeforeTheGreeting()
+ {
+ await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], started: false);
+ ChannelCommandResult result = running.Call.Session.Send(new AddVoiceContextCommand(Fact));
+ await running.Sideband.WaitForDrainAsync();
+ bool sentBeforeTheStart = running.Sideband.Sent.Count > 0;
+
+ running.Sideband.Push([RunningLiveCall.Started]);
+ _ = await running.Sideband.WaitForSentAsync(IsGreeting);
+
+ Assert.Equal(ChannelCommandResult.Scheduled, result);
+ Assert.False(sentBeforeTheStart);
+ List sent = [.. running.Sideband.Sent];
+ Assert.InRange(sent.FindIndex(IsThinking), 0, sent.FindIndex(IsGreeting) - 1);
+ }
+
+ // CallStarted comes before the sideband is attached, so the call's channel must already be there to take the fact.
+ [Fact(Timeout = 30_000)]
+ public async Task AFactAHookSendsFromCallStartedReachesTheVoice()
+ {
+ TaskCompletionSource told = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TellsOnStart hook = new(told);
+ await using OpenAiLiveHost host = await OpenAiLiveHost.StartAsync(new StallOnCueChatClient(), [hook], attachHeldUntil: told.Task);
+ hook.Services = host.Services;
+
+ _ = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123"));
+
+ Assert.Equal(ChannelCommandResult.Scheduled, await told.Task);
+ FakeSideband sideband = await host.FirstAttach.Task;
+ Assert.Equal(Fact, (string?)(await sideband.WaitForSentAsync(IsThinking))["content"]);
+ }
+
+ private static bool IsGreeting(JsonObject sent) => (string?)sent["content"] == RunningLiveCall.Greeting;
+
+ private static bool IsThinking(JsonObject sent) => (string?)sent["type"] == "session.thinking.append";
+
+ private sealed class TellsOnStart(TaskCompletionSource told) : AgentHook
+ {
+ public IServiceProvider? Services { get; set; }
+
+ public override ValueTask BeforeCallAsync(CallGate gate, CancellationToken cancellationToken)
+ {
+ gate.Accept("call_" + gate.CallId);
+ return default;
+ }
+
+ public override async ValueTask OnCallStartedAsync(CallStarted notice, CancellationToken cancellationToken)
+ {
+ IConversationSessions sessions = Services!.GetRequiredService().Sessions;
+ ConversationSession? session = await sessions.TryGetAsync(notice.Scope.Entry!, notice.Scope.ConversationId!, cancellationToken);
+ _ = told.TrySetResult(session?.Send(new AddVoiceContextCommand(Fact)) ?? ChannelCommandResult.NotSupported);
+ }
+ }
+ }
+}
diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs
index 7fe6979..ad08aeb 100644
--- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs
+++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveWebhookTests.cs
@@ -28,7 +28,7 @@ public async Task AnAcceptedCallIsAcceptedWithItsInstructionsVoiceAndClientDeleg
HttpResponseMessage response = await host.PostWebhookAsync(OpenAiLiveHost.IncomingCall("rtc_123"));
FakeSideband sideband = await host.FirstAttach.Task;
- JsonObject greet = await sideband.WaitForSentAsync(sent => (string?)sent["type"] == "session.instructions.append");
+ JsonObject greet = await sideband.WaitForSentAsync(sent => (string?)sent["type"] == "session.commentary.append");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
RecordingLiveControl.Seen accept = Assert.Single(host.Control.Requests);