feat(core): IChatChannel contract and AgentSessionOptions.Channel

Adds the host-agnostic IChatChannel contract (ChatFile, ChatDelivery, ChatChannelLimits, ChatDeliveryException) to Abstractions/Ui, AgentSessionOptions.Channel, AgentSession.Channel and IExtensionContext.Channel. IExtensionHost.Channel is an internal plumbing default member that ExtensionContext forwards. Test support: FakeChatChannel and the TestHost builder's Channel.
This commit is contained in:
EugeneTes
2026-09-26 21:45:57 +02:00
parent a21f3be733
commit f2d6b7e5dc
10 changed files with 128 additions and 1 deletions

View File

@@ -22,6 +22,8 @@ public interface IExtensionContext
/// <summary>False when the host uses <see cref="NullUserInterface"/>; extensions should then avoid dialogs.</summary>
bool HasUi { get; }
IUserInterface Ui { get; }
/// <summary>The session's chat channel, or null when the host has none (then <c>send</c> isn't registered).</summary>
IChatChannel? Channel => null;
IReadOnlySessionManager SessionManager { get; }
IModelRegistry ModelRegistry { get; }
Model? Model { get; }

View File

@@ -0,0 +1,38 @@
namespace Volcanic.AgenticCore.Abstractions.Ui;
/// <summary>How a file should be delivered. <see cref="Auto"/> lets the channel choose.</summary>
public enum ChatFileKind { Auto, Photo, Document }
/// <param name="Path">Absolute path of an existing file; the caller has already confined and validated it.</param>
/// <param name="Kind">Photo, document, or let the channel choose.</param>
/// <param name="Caption">Plain text shown with the file, at most <see cref="ChatChannelLimits.MaxCaptionLength"/>.</param>
public sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null);
/// <param name="SentAs">How a file was actually delivered (Photo or Document); null for text.</param>
/// <param name="MessageId">Host message id(s), for logs and tool details.</param>
public sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId);
/// <summary>Upload limits of a channel, in bytes and characters.</summary>
public sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength);
/// <summary>
/// Delivers content into the conversation's chat, outside the normal assistant-message rendering. Implementations throw
/// only <see cref="ChatDeliveryException"/> and <see cref="OperationCanceledException"/>, are safe to call from any thread,
/// and work while no run is active. Nothing delivered is persisted by the core.
/// </summary>
public interface IChatChannel
{
ChatChannelLimits Limits { get; }
/// <summary>Markdown, rendered like assistant text (split when long). Not added to the session transcript.</summary>
Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken);
/// <summary>
/// Deliver a file. <see cref="ChatFileKind.Auto"/> lets the channel choose, and it may fall back from photo to
/// document. Throws <see cref="ChatDeliveryException"/> with a message fit for the model or the user.
/// </summary>
Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken);
}
/// <summary>A delivery failed; the message is fit for the model or the user.</summary>
public sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception(message, inner);

View File

@@ -16,6 +16,7 @@ public sealed partial class AgentSession : IExtensionHost
{
string IExtensionHost.Scope => _session.Scope;
IUserInterface IExtensionHost.Ui => _ui;
IChatChannel? IExtensionHost.Channel => _options.Channel;
IReadOnlySessionManager IExtensionHost.SessionManager => _session;
IModelRegistry IExtensionHost.ModelRegistry => _models;
Model? IExtensionHost.Model => _model;

View File

@@ -102,6 +102,8 @@ public sealed partial class AgentSession : IAsyncDisposable
public string Cwd => _options.Cwd;
/// <summary>The session's time zone: the session option, else the host default, else the machine's zone.</summary>
public TimeZoneInfo TimeZone => _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
/// <summary>The host's chat channel for this session (e.g. for scheduled deliveries), or null.</summary>
public IChatChannel? Channel => _options.Channel;
public Model? Model => _model;
public ThinkingLevel ThinkingLevel => _thinkingLevel;
public bool IsStreaming { get { lock (_stateLock) return _runTask is { IsCompleted: false }; } }

View File

@@ -15,6 +15,7 @@ internal class ExtensionContext(ExtensionRunner runner, IExtensionHost host, Fun
public string SessionId => Host.SessionId;
public bool HasUi => Host.Ui is not NullUserInterface;
public IUserInterface Ui => Host.Ui;
public IChatChannel? Channel => Host.Channel;
public IReadOnlySessionManager SessionManager => Host.SessionManager;
public IModelRegistry ModelRegistry => Host.ModelRegistry;
public Model? Model => Host.Model;

View File

@@ -15,6 +15,8 @@ public interface IExtensionHost
/// <summary>The session store scope, e.g. <c>telegram-42</c>.</summary>
string Scope { get; }
IUserInterface Ui { get; }
/// <summary>The session's chat channel, or null when the host has none (then <c>send</c> isn't registered).</summary>
IChatChannel? Channel => null;
IReadOnlySessionManager SessionManager { get; }
IModelRegistry ModelRegistry { get; }
Model? Model { get; }

View File

@@ -63,6 +63,8 @@ public sealed class AgentSessionOptions
/// <summary>The conversation's time zone. Falls back to <see cref="AgenticCoreOptions.DefaultTimeZone"/>, then <see cref="TimeZoneInfo.Local"/>.</summary>
public TimeZoneInfo? TimeZone { get; init; }
public IUserInterface? Ui { get; init; }
/// <summary>Delivers files and texts into the conversation's chat; enables the <c>send</c> tool.</summary>
public IChatChannel? Channel { get; init; }
/// <summary>Active tool names. Defaults to read, bash, edit, write.</summary>
public IReadOnlyList<string>? ActiveTools { get; init; }
public ToolExecutionMode ToolExecution { get; init; } = ToolExecutionMode.Parallel;

View File

@@ -0,0 +1,49 @@
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Testing;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
public class AgentSessionFactoryTests
{
public sealed record ProbeArgs(string Value);
private static (LambdaExtension Extension, Func<IChatChannel?> Seen) Probe()
{
IChatChannel? seen = null;
var probe = new DelegateTool<ProbeArgs>("probe", "p", (_, inv, _) => { seen = inv.Context.Channel; return Task.FromResult(ToolResult.Text("ok")); });
return (new LambdaExtension("probe", api => { api.RegisterTool(probe); return ValueTask.CompletedTask; }), () => seen);
}
[Fact]
public async Task Session_and_extension_context_expose_the_channel()
{
var channel = new FakeChatChannel();
var (extension, seen) = Probe();
await using var host = new TestHost(null, extension);
host.Faux.Enqueue(FauxTurn.Tool("probe", new { value = "x" }), FauxTurn.Say("done"));
await using var session = await host.CreateSessionAsync(b => b.Channel = channel);
await session.PromptAsync("go");
Assert.Same(channel, session.Channel);
Assert.Same(channel, seen());
}
[Fact]
public async Task Without_a_channel_both_are_null()
{
var (extension, seen) = Probe();
await using var host = new TestHost(null, extension);
host.Faux.Enqueue(FauxTurn.Tool("probe", new { value = "x" }), FauxTurn.Say("done"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.Null(session.Channel);
Assert.Null(seen());
}
}

View File

@@ -0,0 +1,29 @@
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Tests.Support;
/// <summary>Records deliveries. <see cref="Failure"/> makes every file delivery fail.</summary>
public sealed class FakeChatChannel : IChatChannel
{
public const long MB = 1024 * 1024;
public ChatChannelLimits Limits { get; init; } = new(10 * MB, 50 * MB, 1024);
public List<ChatFile> Files { get; } = [];
public List<string> Texts { get; } = [];
public Exception? Failure { get; set; }
/// <summary>What an <see cref="ChatFileKind.Auto"/> file is reported as.</summary>
public ChatFileKind AutoSendsAs { get; set; } = ChatFileKind.Photo;
public Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken)
{
lock (Texts) Texts.Add(markdown);
return Task.FromResult(new ChatDelivery(null, "t1"));
}
public Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken)
{
if (Failure is not null) return Task.FromException<ChatDelivery>(Failure);
int count;
lock (Files) { Files.Add(file); count = Files.Count; }
return Task.FromResult(new ChatDelivery(file.Kind == ChatFileKind.Auto ? AutoSendsAs : file.Kind, "m" + count));
}
}

View File

@@ -70,12 +70,13 @@ public sealed class TestHost : IAsyncDisposable
public List<ExtensionRegistration> Extra { get; } = [];
public QueueMode SteeringMode { get; set; } = QueueMode.OneAtATime;
public TimeZoneInfo? TimeZone { get; set; }
public Volcanic.AgenticCore.Abstractions.Ui.IChatChannel? Channel { get; set; }
public AgentSessionOptions Build() => new()
{
Cwd = cwd, SessionId = SessionId, Model = Model, ActiveTools = ActiveTools, ToolExecution = ToolExecution,
Ui = Ui, AutoCompaction = AutoCompaction, ExtraExtensions = Extra, SteeringMode = SteeringMode,
TimeZone = TimeZone,
TimeZone = TimeZone, Channel = Channel,
};
}
}