Compare commits
2 Commits
308e1fbf8e
...
bb8c3f43aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb8c3f43aa | ||
|
|
7474cb9620 |
@@ -69,8 +69,9 @@ Design (`PollingService : BackgroundService`, `BotUpdateHandler : IUpdateHandler
|
||||
- `ChatSessions`: one `AgentSession` per chat, lazy; resumes the most recent session in scope `telegram-<chatId>`.
|
||||
- `ChatRenderer`: subscribes once per session, events into a channel, one render task per chat.
|
||||
Placeholder message on assistant `MessageStart`; text deltas edit it at most every 1.5 s; final edit on `MessageEnd`;
|
||||
text over 4096 chars continues in new messages; typing action every 4 s while running; tool lines
|
||||
`▶ bash: <args>` → `✓ bash` / `✗ bash: <excerpt>`; retry, compaction, errors and notifications as short lines.
|
||||
text over 4096 chars continues in new messages; typing action every 4 s while running; one status message
|
||||
per run, "⚙️ Working..." with the last 5 tool calls (`▶` running, `✓` done, `✗` failed), kept as the last message
|
||||
in the chat (deleted and resent when something is sent below it) and deleted when the run settles; retry, compaction, errors and notifications as short lines.
|
||||
Telegram send/edit failures are logged and never stop the render task.
|
||||
- `TelegramFormatter`: Markdown → Telegram HTML (code blocks, inline code, bold, italics, links; everything else
|
||||
escaped); a send that Telegram rejects as bad markup is retried as plain text. Splitting at 4096.
|
||||
|
||||
@@ -38,8 +38,17 @@ its most recent conversation.
|
||||
|
||||
## Using it
|
||||
|
||||
Write normally. The reply streams into one message (split into several past Telegram's 4096 character limit);
|
||||
each tool call shows as a line, `▶ bash: ls -la` becoming `✓ bash` or `✗ bash` with an excerpt of the error.
|
||||
Write normally. While the agent works, a status message stays at the bottom of the chat:
|
||||
|
||||
```
|
||||
⚙️ Working...
|
||||
✓ read: src/Program.cs
|
||||
✗ bash: dotnet test
|
||||
▶ bash: dotnet build
|
||||
```
|
||||
|
||||
It lists the last five tool calls (`▶` running, `✓` done, `✗` failed) and is deleted when the run finishes.
|
||||
The reply streams into its own message above it (split into several past Telegram's 4096 character limit).
|
||||
A message sent while the agent works steers the current run. Photos (with or without a caption) are passed to the model.
|
||||
|
||||
| Command | |
|
||||
|
||||
@@ -13,11 +13,17 @@ namespace Volcanic.AgenticCore.Telegram.Rendering;
|
||||
/// <summary>
|
||||
/// Renders one chat's session events. Session handlers run on the agent loop, so events are queued into a channel and
|
||||
/// rendered by a single task: streamed text edits one message (split into several past the size limit) at most every
|
||||
/// <see cref="TelegramOptions.EditInterval"/>, tool calls become status lines, and "typing…" repeats while the agent runs.
|
||||
/// <see cref="TelegramOptions.EditInterval"/>, and "typing…" repeats while the agent runs.
|
||||
/// <para>
|
||||
/// While a run is active one status message ("⚙️ Working...") lists the last <see cref="StatusToolCount"/> tool calls
|
||||
/// with their state. It is kept as the chat's last message (moved down when something is sent below it) and deleted
|
||||
/// when the run settles.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatRenderer : IAsyncDisposable
|
||||
{
|
||||
private const int ToolExcerptLength = 300;
|
||||
public const int StatusToolCount = 5;
|
||||
private const int StatusArgsLength = 80;
|
||||
|
||||
private readonly long _chatId;
|
||||
private readonly TelegramSender _sender;
|
||||
@@ -30,13 +36,24 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
|
||||
// Streaming reply state; touched only by the render loop.
|
||||
private readonly List<(int MessageId, string Sent)> _replyMessages = [];
|
||||
private readonly Dictionary<string, (int MessageId, string Args)> _toolMessages = [];
|
||||
private string _replyText = "";
|
||||
private bool _replyDirty;
|
||||
private DateTimeOffset _lastFlush = DateTimeOffset.MinValue;
|
||||
private CancellationTokenSource? _typing;
|
||||
private int _pending;
|
||||
|
||||
// Status message state; touched only by the render loop.
|
||||
private enum ToolState { Running, Done, Failed }
|
||||
private sealed record ToolLine(string Id, string Name, string Args) { public ToolState State { get; set; } }
|
||||
private readonly List<ToolLine> _recentTools = [];
|
||||
private bool _running;
|
||||
private int? _statusMessageId;
|
||||
private string? _statusSent;
|
||||
private bool _statusDirty;
|
||||
private bool _statusBuried;
|
||||
|
||||
private bool Dirty => _replyDirty || _statusDirty;
|
||||
|
||||
public ChatRenderer(long chatId, TelegramSender sender, TelegramOptions options, ILogger logger, TimeProvider? time = null)
|
||||
{
|
||||
_chatId = chatId;
|
||||
@@ -57,7 +74,7 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
/// <summary>Completes once every queued event is rendered. For tests.</summary>
|
||||
internal async Task DrainAsync()
|
||||
{
|
||||
while (Volatile.Read(ref _pending) > 0 || _replyDirty) await Task.Delay(10).ConfigureAwait(false);
|
||||
while (Volatile.Read(ref _pending) > 0 || Dirty) await Task.Delay(10).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
@@ -68,12 +85,12 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_replyDirty)
|
||||
if (Dirty)
|
||||
{
|
||||
var wait = _lastFlush + _options.EditInterval - _time.GetUtcNow();
|
||||
if (wait <= TimeSpan.Zero)
|
||||
{
|
||||
await FlushReplyAsync(ct).ConfigureAwait(false);
|
||||
await FlushAsync(ct).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -109,6 +126,7 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
if (_replyDirty) await FlushReplyAsync(ct).ConfigureAwait(false);
|
||||
await DeleteStatusAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -125,9 +143,17 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
{
|
||||
case AgentStart:
|
||||
StartTyping();
|
||||
if (!_running)
|
||||
{
|
||||
_running = true;
|
||||
_recentTools.Clear();
|
||||
await FlushStatusAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
break;
|
||||
case AgentSettled:
|
||||
StopTyping();
|
||||
if (_replyDirty) await FlushReplyAsync(ct).ConfigureAwait(false);
|
||||
await DeleteStatusAsync(ct).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case MessageStart { Message: AssistantMessage }:
|
||||
@@ -149,21 +175,18 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
break;
|
||||
|
||||
case ToolExecutionStart start:
|
||||
// Keep the reply above the tool line in the chat.
|
||||
// Text the model wrote before the call goes out first, so it stays above the status message.
|
||||
if (_replyDirty) await FlushReplyAsync(ct).ConfigureAwait(false);
|
||||
var args = FormatArgs(start.Args);
|
||||
if (await SendHtmlLineAsync($"▶ <b>{TelegramFormatter.Escape(start.ToolName)}</b>{args}", ct).ConfigureAwait(false) is { } id)
|
||||
_toolMessages[start.ToolCallId] = (id, args);
|
||||
_recentTools.Add(new ToolLine(start.ToolCallId, start.ToolName, FormatArgs(start.Args, StatusArgsLength)));
|
||||
if (_recentTools.Count > StatusToolCount) _recentTools.RemoveRange(0, _recentTools.Count - StatusToolCount);
|
||||
_statusDirty = true;
|
||||
break;
|
||||
case ToolExecutionEnd end:
|
||||
var started = _toolMessages.Remove(end.ToolCallId, out var tool);
|
||||
var line = end.IsError
|
||||
? $"✗ <b>{TelegramFormatter.Escape(end.ToolName)}</b>{tool.Args}{FormatExcerpt(end.Result)}"
|
||||
: $"✓ <b>{TelegramFormatter.Escape(end.ToolName)}</b>{tool.Args}";
|
||||
if (started)
|
||||
await _sender.EditHtmlAsync(_chatId, tool.MessageId, line, StripTags(line), ct).ConfigureAwait(false);
|
||||
else
|
||||
await SendHtmlLineAsync(line, ct).ConfigureAwait(false);
|
||||
if (_recentTools.Find(t => t.Id == end.ToolCallId) is { } tool)
|
||||
{
|
||||
tool.State = end.IsError ? ToolState.Failed : ToolState.Done;
|
||||
_statusDirty = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case AutoRetryStart retry:
|
||||
@@ -191,10 +214,16 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FlushAsync(CancellationToken ct)
|
||||
{
|
||||
_lastFlush = _time.GetUtcNow();
|
||||
if (_replyDirty) await FlushReplyAsync(ct).ConfigureAwait(false);
|
||||
if (_statusDirty) await FlushStatusAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task FlushReplyAsync(CancellationToken ct)
|
||||
{
|
||||
_replyDirty = false;
|
||||
_lastFlush = _time.GetUtcNow();
|
||||
if (string.IsNullOrWhiteSpace(_replyText)) return;
|
||||
|
||||
var chunks = TelegramFormatter.Split(_replyText);
|
||||
@@ -211,6 +240,7 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
else if (await _sender.SendHtmlAsync(_chatId, html, chunk, cancellationToken: ct).ConfigureAwait(false) is { } id)
|
||||
{
|
||||
_replyMessages.Add((id, chunk));
|
||||
BuryStatus();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -227,11 +257,65 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
_lastFlush = DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private Task<int?> SendLineAsync(string text, CancellationToken ct) =>
|
||||
_sender.SendHtmlAsync(_chatId, TelegramFormatter.Escape(text), text, cancellationToken: ct);
|
||||
private async Task SendLineAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (await _sender.SendHtmlAsync(_chatId, TelegramFormatter.Escape(text), text, cancellationToken: ct).ConfigureAwait(false) is not null)
|
||||
BuryStatus();
|
||||
}
|
||||
|
||||
private Task<int?> SendHtmlLineAsync(string html, CancellationToken ct) =>
|
||||
_sender.SendHtmlAsync(_chatId, html, StripTags(html), cancellationToken: ct);
|
||||
/// <summary>Something was sent below the status message: move it back to the bottom on the next flush.</summary>
|
||||
private void BuryStatus()
|
||||
{
|
||||
if (_statusMessageId is null) return;
|
||||
_statusBuried = true;
|
||||
_statusDirty = true;
|
||||
}
|
||||
|
||||
/// <summary>Send, move or edit the status message to show the current tool list.</summary>
|
||||
private async Task FlushStatusAsync(CancellationToken ct)
|
||||
{
|
||||
_statusDirty = false;
|
||||
if (!_running) return;
|
||||
var html = StatusHtml();
|
||||
if (_statusBuried && _statusMessageId is { } buried)
|
||||
{
|
||||
await _sender.DeleteAsync(_chatId, buried, ct).ConfigureAwait(false);
|
||||
_statusMessageId = null;
|
||||
}
|
||||
_statusBuried = false;
|
||||
|
||||
if (_statusMessageId is not { } id)
|
||||
{
|
||||
_statusMessageId = await _sender.SendHtmlAsync(_chatId, html, StripTags(html), cancellationToken: ct).ConfigureAwait(false);
|
||||
_statusSent = html;
|
||||
}
|
||||
else if (html != _statusSent && await _sender.EditHtmlAsync(_chatId, id, html, StripTags(html), ct).ConfigureAwait(false))
|
||||
{
|
||||
_statusSent = html;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteStatusAsync(CancellationToken ct)
|
||||
{
|
||||
_running = false;
|
||||
_statusDirty = false;
|
||||
_statusBuried = false;
|
||||
_recentTools.Clear();
|
||||
if (_statusMessageId is { } id) await _sender.DeleteAsync(_chatId, id, ct).ConfigureAwait(false);
|
||||
_statusMessageId = null;
|
||||
_statusSent = null;
|
||||
}
|
||||
|
||||
private string StatusHtml()
|
||||
{
|
||||
var html = new System.Text.StringBuilder("⚙️ <b>Working...</b>");
|
||||
foreach (var tool in _recentTools)
|
||||
{
|
||||
var icon = tool.State switch { ToolState.Done => "✓", ToolState.Failed => "✗", _ => "▶" };
|
||||
html.Append('\n').Append(icon).Append(" <b>").Append(TelegramFormatter.Escape(tool.Name)).Append("</b>").Append(tool.Args);
|
||||
}
|
||||
return html.ToString();
|
||||
}
|
||||
|
||||
private void StartTyping()
|
||||
{
|
||||
@@ -262,7 +346,7 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>": <code>ls -la</code>" for bash, the most telling argument otherwise.</summary>
|
||||
internal static string FormatArgs(JsonElement args)
|
||||
internal static string FormatArgs(JsonElement args, int max = 200)
|
||||
{
|
||||
if (args.ValueKind != JsonValueKind.Object) return "";
|
||||
string? summary = null;
|
||||
@@ -276,13 +360,7 @@ public sealed class ChatRenderer : IAsyncDisposable
|
||||
}
|
||||
summary ??= args.EnumerateObject().Any() ? args.GetRawText() : null;
|
||||
if (string.IsNullOrWhiteSpace(summary)) return "";
|
||||
return $": <code>{TelegramFormatter.Escape(Shorten(summary, 200))}</code>";
|
||||
}
|
||||
|
||||
private static string FormatExcerpt(ToolResult result)
|
||||
{
|
||||
var text = string.Concat(result.Content.OfType<TextContent>().Select(c => c.Text)).Trim();
|
||||
return text.Length == 0 ? "" : $"\n<pre>{TelegramFormatter.Escape(Shorten(text, ToolExcerptLength))}</pre>";
|
||||
return $": <code>{TelegramFormatter.Escape(Shorten(summary.ReplaceLineEndings(" "), max))}</code>";
|
||||
}
|
||||
|
||||
private static string Shorten(string text, int max) => text.Length <= max ? text : text[..max] + "…";
|
||||
|
||||
@@ -89,6 +89,19 @@ public sealed class TelegramSender(ITelegramBotClient bot, ILogger<TelegramSende
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Delete a message; a message that is already gone counts as deleted.</summary>
|
||||
public async Task DeleteAsync(long chatId, int messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.DeleteMessage(chatId, messageId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogDebug(ex, "Deleting message {MessageId} in chat {ChatId} failed", messageId, chatId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendTypingAsync(long chatId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class ChatTests
|
||||
await host.SendAsync("hi");
|
||||
await host.SettleAsync();
|
||||
|
||||
var reply = Assert.Single(host.Bot.Sent);
|
||||
var reply = Assert.Single(host.Bot.Sent, s => !s.Text.StartsWith(Working, StringComparison.Ordinal));
|
||||
Assert.Equal(ParseMode.Html, reply.ParseMode);
|
||||
Assert.Equal("Hello <b>there</b>, how can I help?", host.Bot.CurrentTexts.Values.Single());
|
||||
Assert.Contains(host.Bot.Requests, r => r is SendChatActionRequest);
|
||||
@@ -64,8 +64,10 @@ public sealed class ChatTests
|
||||
Assert.Equal(words, string.Join(" ", texts));
|
||||
}
|
||||
|
||||
private const string Working = "⚙️ <b>Working...</b>";
|
||||
|
||||
[Fact]
|
||||
public async Task Tool_calls_show_a_status_line_that_is_completed()
|
||||
public async Task A_run_shows_a_working_message_that_is_deleted_when_it_settles()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
host.Faux.Enqueue(FauxTurn.Tool("ls", new { path = "." }), FauxTurn.Say("Nothing here."));
|
||||
@@ -73,12 +75,73 @@ public sealed class ChatTests
|
||||
await host.SendAsync("what is here?");
|
||||
await host.SettleAsync();
|
||||
|
||||
var texts = host.Texts.ToList();
|
||||
Assert.Contains("✓ <b>ls</b>: <code>.</code>", texts);
|
||||
Assert.Equal("Nothing here.", texts.Last());
|
||||
Assert.Contains(host.Bot.Sent, s => s.Text.StartsWith("▶ <b>ls</b>", StringComparison.Ordinal));
|
||||
Assert.Equal(["Nothing here."], host.Texts);
|
||||
Assert.Contains(host.Bot.Sent, s => s.Text.StartsWith(Working, StringComparison.Ordinal));
|
||||
Assert.Contains(host.Bot.Requests, r => r is DeleteMessageRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_working_message_lists_the_last_five_tool_calls()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
for (var i = 1; i <= 7; i++) host.Faux.Enqueue(FauxTurn.Tool("ls", new { path = $"dir{i}" }));
|
||||
host.Faux.Enqueue(FauxTurn.Say("Done.").WithDelay(TimeSpan.FromSeconds(30)));
|
||||
|
||||
await host.SendAsync("look everywhere");
|
||||
await Wait.UntilAsync(() => host.Faux.Requests.Count == 8, "the last model call");
|
||||
await Wait.UntilAsync(() => Status(host) is { } s && s.Contains("dir7", StringComparison.Ordinal) && !s.Contains('▶'), "the status with all seven calls finished");
|
||||
|
||||
var lines = Status(host)!.Split('\n');
|
||||
Assert.Equal(Working, lines[0]);
|
||||
// The directories do not exist, so every call failed.
|
||||
Assert.Equal(Enumerable.Range(3, 5).Select(i => $"✗ <b>ls</b>: <code>dir{i}</code>"), lines[1..]);
|
||||
|
||||
await host.SendAsync("/stop");
|
||||
await host.SettleAsync();
|
||||
Assert.Null(Status(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_running_tool_is_marked_until_it_finishes()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
var gate = new TaskCompletionSource();
|
||||
host.Faux.Enqueue(FauxTurn.Tool("bash", new { command = "sleep 1" }), FauxTurn.Say("ok"));
|
||||
host.Bot.OnSend = r =>
|
||||
{
|
||||
// Hold the first status edit that shows the running call until the test has seen it.
|
||||
if (r is EditMessageTextRequest { Text: { } t } && t.Contains("▶", StringComparison.Ordinal)) gate.Task.Wait(5000);
|
||||
};
|
||||
|
||||
await host.SendAsync("sleep");
|
||||
await Wait.UntilAsync(() => host.Bot.Edits.Any(e => e.Text!.Contains("▶ <b>bash</b>: <code>sleep 1</code>", StringComparison.Ordinal)), "the running line");
|
||||
gate.SetResult();
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.Equal(["ok"], host.Texts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_working_message_moves_below_new_reply_text()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
host.Faux.Enqueue(FauxTurn.Say("Let me look.").WithTool("ls", new { }), FauxTurn.Say("Done.").WithDelay(TimeSpan.FromSeconds(30)));
|
||||
|
||||
await host.SendAsync("look");
|
||||
await Wait.UntilAsync(() => host.Faux.Requests.Count == 2, "the second model call");
|
||||
await Wait.UntilAsync(() => Status(host) is { } s && s.Contains('✓'), "the finished ls");
|
||||
|
||||
var inOrder = host.Bot.CurrentTexts.OrderBy(kv => kv.Key).Select(kv => kv.Value).ToList();
|
||||
Assert.Equal("Let me look.", inOrder[0]);
|
||||
Assert.StartsWith(Working, inOrder[^1]);
|
||||
Assert.Equal(2, inOrder.Count);
|
||||
|
||||
await host.SendAsync("/stop");
|
||||
await host.SettleAsync();
|
||||
}
|
||||
|
||||
private static string? Status(ChatHost host) => host.Texts.FirstOrDefault(t => t.StartsWith(Working, StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_aborts_a_running_prompt()
|
||||
{
|
||||
@@ -93,6 +156,7 @@ public sealed class ChatTests
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.Contains("⏹ Stopped.", host.Texts);
|
||||
Assert.Null(Status(host));
|
||||
Assert.DoesNotContain(host.Texts, t => t.Contains("too late", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed class FakeBotClient : ITelegramBotClient
|
||||
public IReadOnlyList<SendMessageRequest> Sent => Requests.OfType<SendMessageRequest>().ToList();
|
||||
public IReadOnlyList<EditMessageTextRequest> Edits => Requests.OfType<EditMessageTextRequest>().ToList();
|
||||
|
||||
/// <summary>The text each sent message shows now (after edits), by message id.</summary>
|
||||
/// <summary>The text each message still in the chat shows now (after edits and deletes), by message id.</summary>
|
||||
public IReadOnlyDictionary<int, string> CurrentTexts => new Dictionary<int, string>(_texts);
|
||||
|
||||
private readonly ConcurrentDictionary<int, string> _texts = new();
|
||||
@@ -94,6 +94,10 @@ public sealed class FakeBotClient : ITelegramBotClient
|
||||
_texts[edit.MessageId] = edit.Text!;
|
||||
result = new Message { Id = edit.MessageId, Text = edit.Text };
|
||||
break;
|
||||
case DeleteMessageRequest delete:
|
||||
_texts.TryRemove(delete.MessageId, out _);
|
||||
result = true;
|
||||
break;
|
||||
default:
|
||||
result = request is GetMeRequest ? new User { Id = BotId, IsBot = true, FirstName = "Fake", Username = "fake_bot" } : true;
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user