Compare commits

...

2 Commits

Author SHA1 Message Date
EugeneTes
bb8c3f43aa feat(telegram): working status message with the last five tool calls 2026-09-25 15:09:43 +00:00
EugeneTes
7474cb9620 chore: decline release notes for this project 2026-09-25 14:43:28 +00:00
7 changed files with 214 additions and 42 deletions

3
CLAUDE.md Normal file
View File

@@ -0,0 +1,3 @@
<!-- release-notes-config
declined: true
-->

View File

@@ -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.

View File

@@ -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 | |

View File

@@ -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] + "…";

View File

@@ -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

View File

@@ -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));
}

View File

@@ -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;