Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs
EugeneTes 891a644590 feat(mcp): remote MCP servers per chat with sealed keys and owner-only commands
Streamable HTTP/SSE servers from the workspace's .volcanic/mcp/servers.json.
Headers are sealed with AES-GCM; the chat and the visible fields are the
associated data, so only /mcp_add|remove|tools change servers. Tools
<server>__<tool> go live in the session. An unreachable server is skipped
and reported in the system prompt. Keys are redacted from tool results, and
Telegram deletes messages that carry one.
2026-10-09 21:11:01 +00:00

190 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Groups;
using Volcanic.AgenticCore.Telegram.Triggers;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
/// <summary>
/// The bot's chat side wired to a <see cref="FakeBotClient"/> and the <see cref="FauxProvider"/>; <paramref name="configure"/>
/// adds services and <paramref name="core"/> adjusts the core options (extensions, tools) before the provider is built;
/// either may capture the host, whose <see cref="Chats"/> exist by the time a session runs.
/// </summary>
public sealed class ChatHost : IAsyncDisposable
{
public FakeBotClient Bot { get; } = new();
public FauxProvider Faux { get; } = new();
public TelegramOptions Options { get; }
/// <summary>The services built from <c>configure</c> and the core (sessions, plus whatever the test added).</summary>
public IServiceProvider Services => _services;
public ChatRegistry Chats { get; }
public UpdateRouter Router { get; }
private readonly ServiceProvider _services;
private readonly string _root;
private readonly bool _ownsRoot;
/// <summary>Per-chat settings (<c>&lt;root&gt;/agent/telegram/chat-settings.json</c>); the default zone is the core option.</summary>
public ChatSettingsStore Settings { get; }
public ChatTimeZoneService Zones { get; }
/// <summary>The host's root directory; a caller-supplied root survives disposal (e.g. for a restart).</summary>
public string Root => _root;
/// <summary>The scheduler when the host was built with <c>scheduler:</c> options; start it with <see cref="StartSchedulerAsync"/>.</summary>
public JobScheduler? Scheduler { get; }
public TelegramJobDelivery? Delivery { get; }
/// <summary><see cref="Updates.AllowedUser"/> as a scheduler target.</summary>
public const string Target = "1001";
public Task StartSchedulerAsync() => Scheduler!.StartAsync(Delivery!, CancellationToken.None);
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null,
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null,
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null, SchedulerOptions? scheduler = null,
IScriptRunner? runner = null, TriggerOptions? triggers = null, IJevClient? jev = null,
Volcanic.AgenticCore.A2A.Server.A2AHostOptions? a2a = null, Volcanic.AgenticCore.Extensions.A2A.A2AClientSettings? a2aClient = null,
Volcanic.AgenticCore.Telegram.Speech.ITextToSpeech? tts = null, Volcanic.AgenticCore.Extensions.Mcp.Remote.RemoteMcpOptions? remoteMcp = null)
{
_ownsRoot = root is null;
_root = root ?? Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
var agentDir = Path.Combine(_root, "agent");
Options = new TelegramOptions
{
BotToken = "test",
AllowedUserIds = [Updates.AllowedUser],
WorkspaceRoot = Path.Combine(_root, "workspaces"),
Model = FauxProvider.ModelKey,
EditInterval = TimeSpan.FromMilliseconds(20),
TypingInterval = TimeSpan.FromMilliseconds(50),
InputDebounce = TimeSpan.FromMilliseconds(20),
};
if (triggers is not null) Options.Triggers = triggers;
var store = new InMemorySessionStore();
var services = new ServiceCollection();
if (time is not null) services.AddSingleton(time); // sessions render the prompt date from it
configure?.Invoke(this, services);
services.AddAgenticCore(o =>
{
o.AgentDir = agentDir;
o.DiscoverExtensionAssemblies = false;
o.DefaultModel = FauxProvider.ModelKey;
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
o.SessionStoreFactory = _ => store;
o.ProcessEnvironment.HardenHostProcess = false;
core?.Invoke(o);
});
_services = services.BuildServiceProvider();
ILoggerFactory loggers = NullLoggerFactory.Instance;
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
var clock = time ?? TimeProvider.System;
var coreOptions = _services.GetRequiredService<Microsoft.Extensions.Options.IOptions<AgenticCoreOptions>>().Value;
Settings = new ChatSettingsStore(ChatSettingsStore.DefaultPath(agentDir), coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local, NullLogger<ChatSettingsStore>.Instance, clock);
if (scheduler is not null)
{
if (string.IsNullOrWhiteSpace(scheduler.LegacyStorePath)) scheduler.LegacyStorePath = SchedulerOptions.DefaultLegacyStorePath(agentDir);
Scheduler = new JobScheduler(scheduler,
runner ?? new ScriptRunner(scheduler, _services.GetRequiredService<IProcessSpawnPolicy>(), NullLogger<ScriptRunner>.Instance, clock),
_services.GetRequiredService<ISecretRedactor>(), clock, NullLogger<JobScheduler>.Instance);
}
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler);
Impersonation = new Impersonation(Options);
if (triggers is not null)
{
Jev = jev ?? new FakeJev();
Groups = new GroupRegistry(GroupRegistry.DefaultPath(agentDir), NullLogger<GroupRegistry>.Instance, clock);
var triggerStore = new TriggerStore(Options.WorkspaceRoot, triggers.MaxTriggersPerChat, NullLogger<TriggerStore>.Instance, clock);
var delivery = new TelegramTriggerDelivery(() => Chats!, clock, NullLogger<TelegramTriggerDelivery>.Instance);
_dispatcher = new TriggerDispatcher(delivery, triggers, clock, NullLogger<TriggerDispatcher>.Instance);
Engine = new TriggerEngine(triggerStore, new SnoozeQueue(triggers.MaxQueuedPerTrigger), Jev, _dispatcher, delivery, clock, TriggerLog);
Triggers = new TriggerServices(Engine, Groups, new TelegramGroupMembership(Bot), triggers);
Feed = new GroupFeed(Groups, Engine, Bot, Options, NullLogger<GroupFeed>.Instance);
}
if (a2aClient is not null) A2AWatchRegistry = new Volcanic.AgenticCore.Extensions.A2A.A2AWatchRegistry(clock, new HttpClientHandler());
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation, Triggers,
a2aClient, A2AWatchRegistry, tts, remoteMcp);
if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger<TelegramJobDelivery>.Instance);
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler, Triggers,
a2a is null ? null : Microsoft.Extensions.Options.Options.Create(a2a), clock, a2aClient, A2AWatchRegistry), sender, Composer, NullLogger<UpdateRouter>.Instance, Impersonation, Feed);
}
private readonly TriggerDispatcher? _dispatcher;
/// <summary>The watch registry when the host was built with <c>a2aClient:</c> settings (this host's own, not the shared one).</summary>
public Volcanic.AgenticCore.Extensions.A2A.A2AWatchRegistry? A2AWatchRegistry { get; }
/// <summary>Set when the host was built with <c>triggers:</c> options.</summary>
public TriggerServices? Triggers { get; }
public TriggerEngine? Engine { get; }
public GroupRegistry? Groups { get; }
public GroupFeed? Feed { get; }
public IJevClient? Jev { get; }
/// <summary>The engine's log lines (one per group message).</summary>
public ListLogger<TriggerEngine> TriggerLog { get; } = new();
/// <summary>Off until <see cref="TelegramOptions.AdminUserId"/> is set on <see cref="Options"/>.</summary>
public Impersonation Impersonation { get; }
/// <summary>The composer the router uses (content steps 1–3).</summary>
public InboundComposer Composer { get; }
/// <summary>The chat's workspace directory (its session's cwd).</summary>
public string WorkspaceOf(long chatId = Updates.AllowedUser) => Chats.WorkspaceFor(chatId);
public Task SendAsync(string text, long userId = Updates.AllowedUser) => Router.RouteAsync(Updates.Text(text, userId), CancellationToken.None);
public Task RouteAsync(Update update) => Router.RouteAsync(update, CancellationToken.None);
public async Task<ChatContext> ChatAsync(long chatId = Updates.AllowedUser) => await Chats.GetAsync(chatId);
/// <summary>Wait until the chat's inbox has flushed, its run has settled and everything it produced is rendered.</summary>
/// <remarks>With a <see cref="Microsoft.Extensions.Time.Testing.FakeTimeProvider"/>, advance past the input window before settling, or the drain times out.</remarks>
public async Task SettleAsync(long chatId = Updates.AllowedUser)
{
var chat = await ChatAsync(chatId);
await chat.Inbox.DrainAsync().WaitAsync(TimeSpan.FromSeconds(10));
await chat.LastPrompt.WaitAsync(TimeSpan.FromSeconds(10));
await Wait.UntilAsync(() => !chat.Session.IsStreaming, "the run to settle");
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>The last message sent to <paramref name="chatId"/>, as plain text (tags stripped, entities decoded).</summary>
public string LastReply(long chatId = Updates.AllowedUser) =>
System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(
Bot.Sent.Last(s => s.ChatId.Identifier == chatId).Text, "<[^>]+>", ""));
public IEnumerable<string> Texts => Bot.CurrentTexts.OrderBy(kv => kv.Key).Select(kv => kv.Value);
public async ValueTask DisposeAsync()
{
if (Scheduler is not null) await Scheduler.StopAsync(CancellationToken.None);
if (Feed is not null) await Feed.IdleAsync();
Engine?.Dispose();
_dispatcher?.Dispose();
await Chats.DisposeAsync();
if (A2AWatchRegistry is not null) await A2AWatchRegistry.DisposeAsync();
await _services.DisposeAsync();
if (_ownsRoot)
try { Directory.Delete(_root, true); } catch (IOException) { }
}
}