fix(telegram): address review findings

- /resume discards the inbox once the id names one conversation, before any await
- /stop discards input still waiting in the window
- a forwarded message is never a command or a pending answer
- ChatInbox.DrainAsync is an internal test helper
- tests: /resume and /stop drop buffered text, forwarded /new is content,
  a failing flush doesn't stop later batches; no wall-clock sleep in the drain test
- spec and user docs: timestamp exception for extension commands, command
  ordering, InputDebounce=0 wording
This commit is contained in:
EugeneTes
2026-09-26 20:15:57 +02:00
parent 0c1778e741
commit 2ec941bf2a
7 changed files with 104 additions and 19 deletions

View File

@@ -46,6 +46,7 @@ public sealed class ChatCommands(TelegramSender sender)
return true;
case "stop":
chat.Inbox.Discard(); // what is still waiting in the window would start the run the user just stopped
if (!session.IsStreaming && !session.IsCompacting)
{
await ReplyAsync(chat, "Nothing is running.", cancellationToken).ConfigureAwait(false);
@@ -156,9 +157,9 @@ public sealed class ChatCommands(TelegramSender sender)
var sessions = await chat.Session.ListSessionsAsync(cancellationToken).ConfigureAwait(false);
var matches = sessions.Where(s => s.SessionId.StartsWith(id, StringComparison.OrdinalIgnoreCase)).ToList();
if (matches.Count != 1) return matches.Count == 0 ? $"No conversation `{id}`." : $"`{id}` matches several conversations; use more characters.";
chat.Inbox.Discard(); // what was typed before /resume belongs to neither conversation; drop it before it can flush
if (chat.Session.IsStreaming) await chat.Session.AbortAsync().ConfigureAwait(false);
var result = await chat.Session.SwitchSessionAsync(matches[0].SessionId, cancellationToken).ConfigureAwait(false);
if (!result.Cancelled) chat.Inbox.Discard(); // what was typed before /resume belongs to the old conversation
return result.Cancelled ? "An extension cancelled the switch." : $"Resumed `{matches[0].SessionId}` ({matches[0].MessageCount} messages).";
}

View File

@@ -68,8 +68,9 @@ public sealed class UpdateRouter(
var chat = await chats.GetAsync(message.Chat.Id, cancellationToken).ConfigureAwait(false);
var text = message.Text ?? message.Caption ?? "";
// Commands and pending answers are checked before the inbox, and only for messages without an attachment.
if (!InboundComposer.HasAttachment(message))
// Commands and pending answers are checked before the inbox, and only for messages without an attachment that
// weren't forwarded: a forwarded message is content, never a command or an answer.
if (!InboundComposer.HasAttachment(message) && message.ForwardOrigin is null)
{
if (ChatCommands.Parse(text) is not null)
{
@@ -78,8 +79,7 @@ public sealed class UpdateRouter(
_ = chat.PromptInBackground(text);
return;
}
// A forwarded message is content, not an answer.
if (message.ForwardOrigin is null && chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
if (chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
}
// Take the lease now, in update order; compose (downloads) in the background so polling isn't blocked.

View File

@@ -77,14 +77,14 @@ public sealed class ChatInbox : IAsyncDisposable
}
}
/// <summary>Drop buffered parts and in-flight leases (<c>/new</c>, <c>/resume</c>). Leases already open complete into nothing.</summary>
/// <summary>Drop buffered parts and in-flight leases (<c>/new</c>, <c>/stop</c>, <c>/resume</c>). Leases already open complete into nothing.</summary>
public void Discard()
{
lock (_lock) DropLocked();
}
/// <summary>Completes when nothing is buffered, no lease is open and the last flush was handed over.</summary>
public async Task DrainAsync()
/// <summary>Completes when nothing is buffered, no lease is open and the last flush was handed over (test helper; polls).</summary>
internal async Task DrainAsync()
{
while (true)
{