fix(telegram): address review findings

- A speech or download timeout (TaskCanceledException nobody asked for) is a failure
  with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to
  "the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown.
- send: WorkspacePaths resolves the real path component by component (realpath),
  closing the directory-link-inside-target bypass; `.` reports a directory.
- Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP,
  under the detected type.
- Audio goes to STT as an ASCII audio.<ext> name.
- MCP Browser section mentions send only when it is active.
- RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up;
  SendReplyAsync stays private behind ReplyInOrderAsync.
- Speech HttpClient handler recycles pooled connections; BuiltInTools.Send();
  IExtensionHost.Channel is abstract; WorkspacePaths in its own file.
- Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures,
  queued-work cancellation, gated voice ordering. Spec and docs updated.
This commit is contained in:
EugeneTes
2026-09-26 22:35:20 +02:00
parent c5ecd017a0
commit 6ab98a8155
25 changed files with 536 additions and 93 deletions

View File

@@ -78,7 +78,7 @@ Configured values win over the variables; an empty variable counts as unset. The
- Each chat has its own browser profile under `.volcanic/browser-profile`, so site logins survive idle restarts,
`/new` and bot restarts. The agent's `bash` can read that profile, like everything else in its workspace.
- The system prompt gets a short "Browser" section while the browser tools are active. It mentions `send` only when
a `send` tool exists.
a `send` tool is active.
### Deployment requirements

View File

@@ -194,8 +194,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
- Don't enter passwords or payment details unless the user explicitly asks for that specific site.
```
The `send` line is included only when a tool named `send` is registered (checked at prompt time through
`GetAllTools()`), so the preset still reads correctly on hosts without attachments. Without `send`, that bullet
The `send` line is included only when a tool named `send` is active (checked at prompt time through
`GetActiveTools()`; a registered but inactive `send` isn't callable), so the preset still reads correctly on hosts
without attachments. Without `send`, that bullet
reads: "Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are
under browser/ in the workspace."

View File

@@ -118,10 +118,14 @@ ExecutionMode: Sequential (two sends in one turn arrive in call order)
Execution:
1. `ctx.Channel` is null → throw `send is not available: this session has no chat` (defensive; see registration).
2. Resolve with the existing `Paths.Resolve(ctx.Cwd, path)`. Reject unless the full path is inside `ctx.Cwd` (compare
with a trailing separator, case-insensitively on Windows). If the file or any parent is a symlink, resolve it with
`ResolveLinkTarget(returnFinalTarget: true)` and apply the same check to the target. Errors are
`path escapes the workspace: <path>`, `file not found: <path>` and `path is a directory, not a file: <path>`.
2. Resolve with the existing `Paths.Resolve(ctx.Cwd, path)`. Reject unless the full path is inside `ctx.Cwd` or is
`ctx.Cwd` itself (compare with a trailing separator, case-insensitively on Windows). Then resolve the real path the
way `realpath(3)` does (`WorkspacePaths.RealPath`): walk it from the filesystem root and replace each link by its
target as it is met, so links inside a link's target are followed too and a `..` in a target applies to the real
parent. That real path must be inside the real path of `ctx.Cwd`. More than 40 links (a loop) also fails.
(`ResolveLinkTarget(returnFinalTarget: true)` isn't enough: on Unix it follows only the last component of each hop,
so `a/l -> ../d/secret` with `d -> /etc` would pass.) Errors are `path escapes the workspace: <path>`,
`file not found: <path>` and `path is a directory, not a file: <path>` (also for `.`, the workspace itself).
3. Caption longer than `Limits.MaxCaptionLength` → `caption is <n> characters; the limit is <m>. Shorten it or put
the text in your reply.` The check runs before anything is sent, so a failure never delivers half an attachment.
4. Kind:
@@ -151,13 +155,16 @@ it (`chat.Channel`).
- `Limits`: photo 10 MB, document 50 MB, caption 1024 (Bot API limits), in `TelegramLimits` constants.
- **Ordering through the renderer.** Session events are rendered asynchronously from `ChatRenderer`'s queue. A file
sent directly could overtake an assistant message that ended just before the tool ran. `ChatRenderer` gets
`Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work)`. It queues a host-internal work item behind the
pending events and completes when the item has run. The renderer marks the status message as buried afterwards, as
`Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)`.
It queues a host-internal work item behind the pending events and completes when the item has run. The caller's
token cancels the returned task at once, even while the item still waits behind a slow upload; an item cancelled
before it starts is skipped, and a running one sees the token through the one it's given. The renderer marks the status message as buried afterwards, as
it does for replies, so the `⚙️ Working...` message moves back to the bottom on the next tool update.
`SendTextAsync` and `SendFileAsync` both go through it. The event channel becomes a channel of render items (an
event or a work item). A work item still queued when the renderer is disposed is cancelled. `ChatRenderer.SayAsync(text)`
(a `RunInOrderAsync` around `TelegramSender.SendTextAsync`) sends plain-text notices and the `🎤` echo. The reply path
(`ChatRenderer.SendReplyAsync`) returns the first message id and whether every chunk was delivered.
event or a work item). Disposing the renderer lets it drain its queue for up to 5 s; a work item still queued after
that, or queued after disposal, is cancelled without running. `ChatRenderer.SayAsync(text, ct)` (a `RunInOrderAsync`
around `TelegramSender.SendTextAsync`) sends plain-text notices and the `🎤` echo. `ChatRenderer.ReplyInOrderAsync`
runs the (private) reply path on the loop and returns the first message id and whether every chunk was delivered.
- **Files.** `TelegramSender.SendFileAsync(chatId, path, asPhoto, caption, ct) → Task<FileSendResult>`, with
`FileSendResult(int? MessageId, string? Error, bool PhotoRejected)`. It follows the sender's never-throw convention.
Each attempt opens a fresh `FileStream` and uses `InputFile.FromStream(stream, Path.GetFileName(path))` with
@@ -218,7 +225,12 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp
`[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. The path is relative to the workspace
and uses `/`. Sizes read `N bytes`, `N.N KB` or `N.N MB`.
5. Image documents: when `MimeType` is `image/jpeg|png|webp|gif` and the size is ≤ `Telegram:InlineImageMaxBytes`
(default 3 750 000), the saved bytes are also attached as `ImageContent(base64, mime)`. Larger images are
(default 3 750 000), the saved bytes are also attached as `ImageContent(base64, mime)`. Documents aren't
re-encoded by Telegram, so the declared type can be wrong (HEIC named `.png`, a truncated file); a provider rejects
such an image, and the persisted prompt would then break every later turn. So the leading bytes decide: JPEG
`FF D8 FF`, PNG `89 50 4E 47 0D 0A 1A 0A`, GIF `47 49 46 38` or WebP `RIFF....WEBP`, and the image is attached
under the type its bytes show. Bytes that are none of these make the file path-only, with the suffix
` (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)`. Larger images are
path-only, and the line gets a parenthesised suffix after the size:
`[File received: inbox/x.png (image/png, 5.0 MB) (too large to show inline; downscale it with bash if you need to see it)]`.
@@ -231,10 +243,15 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp
refused it).
3. Voice is downloaded into memory; a failed download sends `I could not download that file.` and the message isn't
prompted. Audio is read back from the file step 2 saved, so it's downloaded only once. Voice arrives as `.oga`,
which OpenAI rejects, so it's renamed to `voice.ogg` (`audio/ogg`). Audio keeps `FileName ?? "audio"` and its
`MimeType`.
which OpenAI rejects, so it's renamed to `voice.ogg` (`audio/ogg`). OpenAI picks the format from the file
extension, so audio goes as an ASCII `audio.<ext>`: the original extension when OpenAI knows it (`flac`, `m4a`,
`mp3`, `mp4`, `mpeg`, `mpga`, `ogg`, `wav`, `webm`; `.oga`/`.opus` become `.ogg`), else one from `MimeType`
(`mp3` when unknown), with its `MimeType`. A missing or non-ASCII name would otherwise lose the extension.
4. `Transcript = await stt.TranscribeAsync(bytes, fileName, mime, ct)`, trimmed. Empty → notice `I couldn't make
out any words in that voice message.` An exception → notice `Could not transcribe the voice message: <reason>`.
That includes a cancellation the message's own token didn't cause, such as the `HttpClient` timeout; only a
shutdown (the caller's token) ends composing silently. The same rule holds for downloads, and `UpdateRouter`
treats any other `OperationCanceledException` from `ComposeAsync` as a failure (`I could not read that message.`).
5. Echo `🎤 <transcript>` as plain text, cut to 4096 characters with `…`.
6. Prompt text: the caption (if any), then `[Voice message] <transcript>`. For `Audio`, the file is also saved to
the inbox like a document and gets its own `[File received: …]` line: it's something the user might want
@@ -261,7 +278,13 @@ public interface ISpeechToText
as given), `model`, `response_format=json`, and `language` when configured.
- Non-2xx → `SpeechToTextException("OpenAI transcription failed: HTTP <status> <first 200 chars of body>")`.
A non-JSON body or a missing `text` gives its own message. The key never appears in messages or logs.
- The client timeout (a `TaskCanceledException` while the caller's token isn't cancelled) →
`SpeechToTextException("OpenAI transcription failed: the request timed out.")`; a transport failure
(`HttpRequestException`) → the same prefix plus its message. The caller's own cancellation still propagates.
- `HttpClient` comes from `AddHttpClient<ISpeechToText, OpenAiSpeechToText>()` (package `Microsoft.Extensions.Http`), with a 2-minute timeout.
The singleton `InboundMedia` keeps that typed client for the process lifetime, so the factory never rotates its
handler: the primary handler is a `SocketsHttpHandler` with `PooledConnectionLifetime = 5 min` (and an infinite
handler lifetime), so DNS changes are still picked up.
`SpeechOptions`, the `Telegram:Speech` section:
@@ -274,7 +297,7 @@ public interface ISpeechToText
The environment fallbacks (`OPENAI_API_KEY`, `STT_MODEL`) are read in `TelegramOptions.ApplyEnvironment`, next to
the existing `TELEGRAM_*` shortcuts, and only when the `Telegram:Speech` setting is empty. An empty variable counts
as unset (the Docker compose file passes both through, empty by default). The OpenAI provider
as unset (a deployment, such as a Docker compose file, may pass both through empty). The OpenAI provider
extension, if loaded, reads `OPENAI_API_KEY` on its own; the two don't share state.
`Program.cs` registers `ISpeechToText` only when a key is present, and logs `Speech to text: <model>` or
`Speech to text: off (no OPENAI_API_KEY)`. `OPENAI_API_KEY` isn't on the Claude Code provider's denied-variable
@@ -304,7 +327,8 @@ accepts the consequence that the agent doesn't see them.
| file > 20 MB sent to the bot | size notice, nothing reaches the agent |
| download fails | `I could not download that file.`, partial file deleted, no prompt |
| voice with no STT configured | not-configured notice; caption (if any) is still prompted |
| STT HTTP/parse error | `Could not transcribe the voice message: …`, no prompt unless there's a caption |
| STT HTTP/parse error or timeout | `Could not transcribe the voice message: …`, no prompt unless there's a caption (an audio file's `[File received: …]` line is still prompted) |
| image document whose bytes aren't JPEG/PNG/WebP/GIF | saved and prompted path-only, with the "not shown inline" note |
| `send` path outside the workspace / missing / directory | error tool result, nothing sent |
| `send` over the photo / document limit | error tool result naming the limit |
| caption over 1024 | error tool result, nothing sent |
@@ -341,7 +365,9 @@ xunit, no network, no paid API.
- `auto` `.png` passes `Auto`; `auto` `.pdf` and `.gif` pass `Document`; explicit `photo`/`document` are honoured.
- An `auto` image over the photo limit goes as `Document`; an explicit `photo` over the limit fails.
- A document over the limit, a caption over the limit, `../x`, an absolute path outside, and a symlink inside the
workspace pointing outside all fail, and the channel is never called.
workspace pointing outside all fail, and the channel is never called. So do (Unix only) a link whose target runs
through a directory link pointing outside (`a/l -> ../d/secret`, `d -> <outside>`), a link whose target has a `..`
after a directory link (`l -> x/../secret`, `x -> <outside>/deep`) and a link loop. `.` is a directory.
- Missing file and directory errors. Absolute paths inside the workspace are accepted.
- A `ChatDeliveryException` becomes an error tool result with its message.
- Result text and details. Sparse files (`FileStream.SetLength`) stand in for big files.
@@ -374,9 +400,9 @@ xunit, no network, no paid API.
note.
- Voice with STT: the `🎤` echo comes before the answer, the prompt contains `[Voice message] …`, and the file name
handed to STT ends in `.ogg`. A 5000-character transcript echo is cut to 4096.
- Voice without STT: the not-configured notice, and no run. STT throwing: the failure notice, and no run.
- Voice without STT: the not-configured notice, and no run. STT throwing, or timing out (a `TaskCanceledException` nobody asked for): the failure notice, and no run.
- Ordering: a voice note followed immediately by text produces one prompt with the transcript before the text (the
fake STT is delayed past the debounce window). `/stop` sent during a slow transcription is handled at once.
fake STT is held on a gate while a `FakeTimeProvider` moves well past the debounce window, and nothing is prompted until it is released). `/stop` sent during a slow transcription is handled at once.
- A document with a `/new` caption doesn't run `/new`. A voice note doesn't answer a pending `InputAsync`.
- A sticker gets the "I can read…" reply.
- `send` end to end: the faux provider calls `send` for `out/chart.png`, a `sendPhoto` upload with the caption
@@ -389,7 +415,7 @@ xunit, no network, no paid API.
**Speech (`OpenAiSpeechToTextTests`)** with a stub `HttpMessageHandler`: the multipart form has `file` (name and
content type), `model` and `response_format=json`, plus `language` when set; the bearer header is present; the
transcript is trimmed; non-2xx, non-JSON and missing `text` each give their own message, which never contains the
key.
key. A client timeout is a `SpeechToTextException`; the caller's cancellation stays a cancellation.
## Out of scope

View File

@@ -119,8 +119,8 @@ question in that chat (for example from an extension) replaces the pending promp
- **Documents, videos and audio files** are saved to `inbox/<yyyy-MM-dd>-<name>` in the chat's workspace (a number is
added when the name is taken: `-1`, `-2`, ...). The agent gets the caption plus a line such as
`[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. An image sent "as file" (JPEG, PNG, WebP,
GIF) up to 3.75 MB (`Telegram:InlineImageMaxBytes`) is also shown to the model; a larger one reaches it as a path
only.
GIF) up to 3.75 MB (`Telegram:InlineImageMaxBytes`) is also shown to the model; a larger one, or one whose contents
aren't really one of those formats, reaches it as a path only.
- **Voice messages and audio files** are transcribed with OpenAI (`gpt-4o-transcribe` by default). The bot echoes
`🎤 <transcript>` and the agent gets `[Voice message] <transcript>` (an audio file also gets its `[File received: …]`
line). Without an API key the bot replies with how to set one up (`OPENAI_API_KEY`).

View File

@@ -129,7 +129,7 @@ public sealed class McpExtension : IExtension
private void AddPromptSections(IExtensionApi api, SystemPromptOptions options)
{
var active = api.GetActiveTools().ToHashSet(StringComparer.Ordinal);
var hasSend = api.GetAllTools().Any(t => t.Name == "send");
var hasSend = active.Contains("send"); // registered but inactive: the model can't call it
foreach (var server in _servers)
{
if (!server.RegisteredTools.Any(active.Contains)) continue;

View File

@@ -17,11 +17,7 @@ public sealed class TelegramChatChannel(long chatId, TelegramSender sender, Chat
public async Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(markdown);
var (first, complete) = await renderer.RunInOrderAsync(async ct =>
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
return await renderer.SendReplyAsync(markdown, linked.Token).ConfigureAwait(false);
}).ConfigureAwait(false);
var (first, complete) = await renderer.ReplyInOrderAsync(markdown, cancellationToken).ConfigureAwait(false);
if (!complete) throw new ChatDeliveryException("Telegram did not accept the message.");
return new ChatDelivery(null, first?.ToString(CultureInfo.InvariantCulture));
}
@@ -31,18 +27,17 @@ public sealed class TelegramChatChannel(long chatId, TelegramSender sender, Chat
ArgumentNullException.ThrowIfNull(file);
return renderer.RunInOrderAsync(async ct =>
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path));
var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, linked.Token).ConfigureAwait(false);
var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, ct).ConfigureAwait(false);
if (result.MessageId is { } id) return Delivered(asPhoto, id);
if (asPhoto && file.Kind == ChatFileKind.Auto && result.PhotoRejected)
{
// Telegram refuses some images as photos (dimensions, aspect ratio): send the file as a document instead.
result = await sender.SendFileAsync(chatId, file.Path, asPhoto: false, file.Caption, linked.Token).ConfigureAwait(false);
result = await sender.SendFileAsync(chatId, file.Path, asPhoto: false, file.Caption, ct).ConfigureAwait(false);
if (result.MessageId is { } documentId) return Delivered(false, documentId);
}
throw new ChatDeliveryException($"Telegram refused the file: {result.Error}");
});
}, cancellationToken);
}
private bool LooksLikePhoto(string path) =>

View File

@@ -92,7 +92,7 @@ public sealed class UpdateRouter(
{
lease.Complete(await composer.ComposeAsync(chat, message, cancellationToken).ConfigureAwait(false));
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// shutting down: the lease completes empty
}

View File

@@ -81,7 +81,7 @@ public sealed class InboundComposer(TelegramSender sender, InboundMedia media, I
await media.DownloadAsync(largest.FileId, stream, cancellationToken).ConfigureAwait(false);
draft.Images.Add(new ImageContent(Convert.ToBase64String(stream.ToArray()), "image/jpeg"));
}
catch (Exception ex) when (ex is not OperationCanceledException)
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(ex, "Downloading a photo in chat {ChatId} failed", chat.ChatId);
chat.Report("I could not download that photo.");

View File

@@ -29,6 +29,9 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
/// <summary>Suffix of a <c>[File received: …]</c> line for an image too large to attach inline.</summary>
public const string TooLargeInlineNote = " (too large to show inline; downscale it with bash if you need to see it)";
/// <summary>Suffix of a <c>[File received: …]</c> line for an image document whose bytes aren't a known image.</summary>
public const string NotAnImageNote = " (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)";
private static readonly HashSet<string> InlineImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp", "image/gif" };
/// <summary>The notice for a file over the 20 MB download limit; <c>over 20 MB</c> when the size is unknown.</summary>
@@ -67,7 +70,7 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
await using (stream.ConfigureAwait(false))
await DownloadAsync(fileId, stream, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
TryDelete(path);
throw;
@@ -88,8 +91,12 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
{
if (bytes <= options.InlineImageMaxBytes)
{
// Documents aren't re-encoded by Telegram, so the declared type can be wrong (HEIC named .png, a
// truncated file). A provider rejects such an image, and the persisted prompt would break every later
// turn: attach only bytes that really are one of the four types, under the type they show.
var data = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
draft.Images.Add(new ImageContent(Convert.ToBase64String(data), mime.ToLowerInvariant()));
if (SniffImageType(data) is { } actual) draft.Images.Add(new ImageContent(Convert.ToBase64String(data), actual));
else note = NotAnImageNote;
}
else
{
@@ -109,7 +116,7 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
var (fileId, size, name, mime, isVoice) = message switch
{
{ Voice: { } v } => (v.FileId, v.FileSize, "voice.ogg", "audio/ogg", true), // Telegram's .oga is rejected by OpenAI
{ Audio: { } a } => (a.FileId, a.FileSize, a.FileName ?? "audio", a.MimeType ?? "audio/mpeg", false),
{ Audio: { } a } => (a.FileId, a.FileSize, SpeechFileName(a.FileName, a.MimeType), a.MimeType ?? "audio/mpeg", false),
_ => ((string?)null, (long?)null, "", "", false),
};
if (fileId is null) return;
@@ -137,7 +144,7 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
{
await DownloadAsync(fileId, buffer, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
catch (Exception ex) when (!cancellationToken.IsCancellationRequested) // a client timeout is a failure, not a shutdown
{
logger.LogWarning(ex, "Downloading a voice message in chat {ChatId} failed", chat.ChatId);
await chat.Renderer.SayAsync(ex is ApiRequestException api && IsTooBig(api) ? TooBigNotice(size) : DownloadFailedNotice).ConfigureAwait(false);
@@ -152,7 +159,7 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
{
transcript = (await speech.TranscribeAsync(audio, name, mime, cancellationToken).ConfigureAwait(false)).Trim();
}
catch (Exception ex) when (ex is not OperationCanceledException)
catch (Exception ex) when (!cancellationToken.IsCancellationRequested) // a client timeout is a failure, not a shutdown
{
logger.LogWarning(ex, "Transcribing a voice message in chat {ChatId} failed", chat.ChatId);
await chat.Renderer.SayAsync($"Could not transcribe the voice message: {ex.Message}").ConfigureAwait(false);
@@ -167,6 +174,36 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
draft.Body.Add($"[Voice message] {transcript}");
}
/// <summary>
/// The name an audio file is sent to speech to text under: OpenAI picks the format from the extension, so it's an
/// ASCII <c>audio.&lt;ext&gt;</c> from the original extension when OpenAI knows it, else from the MIME type.
/// </summary>
internal static string SpeechFileName(string? fileName, string? mimeType)
{
var ext = Path.GetExtension(fileName ?? "").ToLowerInvariant();
if (ext is ".flac" or ".m4a" or ".mp3" or ".mp4" or ".mpeg" or ".mpga" or ".ogg" or ".wav" or ".webm") return "audio" + ext;
if (ext is ".oga" or ".opus") return "audio.ogg";
return "audio" + (mimeType?.ToLowerInvariant() switch
{
"audio/mp4" or "audio/m4a" or "audio/x-m4a" => ".m4a",
"audio/ogg" or "audio/opus" => ".ogg",
"audio/wav" or "audio/x-wav" or "audio/wave" => ".wav",
"audio/flac" or "audio/x-flac" => ".flac",
"audio/webm" => ".webm",
_ => ".mp3", // audio/mpeg, Telegram's default for audio
});
}
/// <summary>The image type the leading bytes show (JPEG, PNG, GIF or WebP), or null.</summary>
internal static string? SniffImageType(ReadOnlySpan<byte> data) => data switch
{
[0xFF, 0xD8, 0xFF, ..] => "image/jpeg",
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..] => "image/png",
[0x47, 0x49, 0x46, 0x38, ..] => "image/gif",
[0x52, 0x49, 0x46, 0x46, _, _, _, _, 0x57, 0x45, 0x42, 0x50, ..] => "image/webp",
_ => null,
};
private void TryDelete(string path)
{
try { File.Delete(path); }

View File

@@ -42,7 +42,11 @@ builder.Services.AddSingleton(options);
builder.Services.AddSingleton(mcp);
builder.Services.AddSingleton(options.Speech);
if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the setup notice
builder.Services.AddHttpClient<ISpeechToText, OpenAiSpeechToText>(client => client.Timeout = TimeSpan.FromMinutes(2));
// InboundMedia (a singleton) keeps this typed client for the process lifetime, so the factory never rotates its
// handler: let the handler recycle its pooled connections instead, so DNS changes are still picked up.
builder.Services.AddHttpClient<ISpeechToText, OpenAiSpeechToText>(client => client.Timeout = TimeSpan.FromMinutes(2))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves

View File

@@ -41,7 +41,8 @@ public sealed class ChatRenderer : IAsyncDisposable
private readonly CancellationTokenSource _stop = new();
private readonly Task _loop;
private static readonly TimeSpan[] ReplyRetryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
/// <summary>Delays before resending a reply chunk that failed (1 s, 3 s). Settable for tests.</summary>
internal IReadOnlyList<TimeSpan> ReplyRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
private DateTimeOffset _lastFlush = DateTimeOffset.MinValue;
private CancellationTokenSource? _typing;
@@ -83,23 +84,49 @@ public sealed class ChatRenderer : IAsyncDisposable
/// <summary>
/// Run host work (an upload, a notice) on the render loop, after every event queued before it, so it stays in order
/// with rendered output. Afterwards the status message counts as buried. Cancelled when the renderer is disposed first.
/// <paramref name="cancellationToken"/> cancels the returned task at once, even while the work is still queued behind
/// a slow upload; work that hasn't started by then is skipped, and running work sees it through its token.
/// </summary>
public Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work)
public Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(work);
if (cancellationToken.IsCancellationRequested) return Task.FromCanceled<T>(cancellationToken);
var done = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var registration = cancellationToken.Register(() => done.TrySetCanceled(cancellationToken));
var item = new WorkItem(async ct =>
{
try { done.TrySetResult(await work(ct).ConfigureAwait(false)); }
try
{
if (done.Task.IsCompleted) return; // cancelled while queued
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
done.TrySetResult(await work(linked.Token).ConfigureAwait(false));
}
catch (OperationCanceledException) { done.TrySetCanceled(); }
catch (Exception ex) { done.TrySetException(ex); }
}, () => done.TrySetCanceled());
if (!Enqueue(item)) done.TrySetCanceled();
finally { await registration.DisposeAsync().ConfigureAwait(false); }
}, () =>
{
registration.Dispose();
done.TrySetCanceled();
});
if (!Enqueue(item))
{
registration.Dispose();
done.TrySetCanceled();
}
return done.Task;
}
/// <summary>Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender.</summary>
public Task<int?> SayAsync(string text) => RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct));
public Task<int?> SayAsync(string text, CancellationToken cancellationToken = default) =>
RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct), cancellationToken);
/// <summary>
/// A Markdown reply rendered like an assistant message (HTML, split past 4096, each chunk retried), in order with
/// rendered output. Returns the first message id and whether every chunk was delivered.
/// </summary>
internal Task<(int? FirstId, bool Complete)> ReplyInOrderAsync(string markdown, CancellationToken cancellationToken) =>
RunInOrderAsync(ct => SendReplyAsync(markdown, ct), cancellationToken);
/// <summary>Completes once every queued event is rendered. For tests.</summary>
internal async Task DrainAsync()
@@ -148,6 +175,9 @@ public sealed class ChatRenderer : IAsyncDisposable
case EventItem { Event: var e }:
await RenderAsync(e, ct).ConfigureAwait(false);
break;
case WorkItem work when ct.IsCancellationRequested: // disposal gave up waiting
work.Cancel();
break;
case WorkItem work:
await work.Run(ct).ConfigureAwait(false);
BuryStatus();
@@ -254,7 +284,7 @@ public sealed class ChatRenderer : IAsyncDisposable
/// Send a finished assistant message. A chunk that fails to send is retried a couple of times. Returns the first
/// message id and whether every chunk was delivered.
/// </summary>
internal async Task<(int? FirstId, bool Complete)> SendReplyAsync(string text, CancellationToken ct)
private async Task<(int? FirstId, bool Complete)> SendReplyAsync(string text, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(text)) return (null, true);
int? first = null;

View File

@@ -21,8 +21,29 @@ public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) :
using var request = new HttpRequestMessage(HttpMethod.Post, options.BaseUrl.TrimEnd('/') + "/audio/transcriptions") { Content = form };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
HttpResponseMessage response;
string body;
try
{
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) // HttpClient.Timeout
{
throw new SpeechToTextException(Prefix + "the request timed out.", ex);
}
catch (HttpRequestException ex)
{
throw new SpeechToTextException(Prefix + Hide(ex.Message), ex);
}
using var _ = response;
try
{
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new SpeechToTextException(Prefix + "the request timed out.", ex);
}
if (!response.IsSuccessStatusCode)
{
var hidden = Hide(body); // hide the key before cutting, so half a key can't leak

View File

@@ -11,7 +11,6 @@ using Volcanic.AgenticCore.Features.Models;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Features.SystemPrompt;
using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Features.Tools.BuiltIn;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Features.Agent;
@@ -72,7 +71,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
// `send` exists only when the host can deliver into this session's chat.
if (options.Channel is not null)
{
var send = new SendTool();
var send = BuiltInTools.Send();
tools.AddBuiltIn(send, options.ActiveTools?.Contains(send.Name) ?? true);
}
}

View File

@@ -16,7 +16,7 @@ public interface IExtensionHost
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;
IChatChannel? Channel { get; }
IReadOnlySessionManager SessionManager { get; }
IModelRegistry ModelRegistry { get; }
Model? Model { get; }

View File

@@ -66,36 +66,3 @@ public sealed class SendTool : Tool<SendArgs>
private static string Mb(long bytes) => (bytes / 1048576.0).ToString("0.0", CultureInfo.InvariantCulture);
}
/// <summary>Workspace confinement for tools that hand files to the outside world.</summary>
internal static class WorkspacePaths
{
private static StringComparison Comparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
/// <summary>True when <paramref name="fullPath"/> is strictly below <paramref name="root"/> (trailing-separator compare).</summary>
public static bool IsInside(string root, string fullPath) =>
fullPath.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, Comparison);
/// <summary>Workspace-relative path with <c>/</c> separators.</summary>
public static string Relative(string root, string fullPath) => Path.GetRelativePath(root, fullPath).Replace('\\', '/');
/// <summary>
/// The full path of <paramref name="path"/> when it, and the final target of every link between it and the
/// workspace, stays inside <paramref name="root"/>. Throws <c>path escapes the workspace: …</c> otherwise.
/// </summary>
public static string Confine(string root, string path)
{
var full = Paths.Resolve(root, path);
if (!IsInside(root, full)) throw Escapes(path);
for (var current = full; IsInside(root, current); current = Path.GetDirectoryName(current)!)
{
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
if (info.LinkTarget is null) continue;
var target = info.ResolveLinkTarget(returnFinalTarget: true);
if (target is null || !IsInside(root, Path.GetFullPath(target.FullName))) throw Escapes(path);
}
return full;
}
private static InvalidOperationException Escapes(string path) => new($"path escapes the workspace: {path}");
}

View File

@@ -0,0 +1,98 @@
namespace Volcanic.AgenticCore.Features.Tools.BuiltIn;
/// <summary>Workspace confinement for tools that hand files to the outside world.</summary>
internal static class WorkspacePaths
{
/// <summary>Links followed while resolving one path before it counts as a loop (Linux's <c>MAXSYMLINKS</c>).</summary>
private const int MaxLinkHops = 40;
private static StringComparison Comparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
/// <summary>True when <paramref name="fullPath"/> is strictly below <paramref name="root"/> (trailing-separator compare).</summary>
public static bool IsInside(string root, string fullPath) =>
fullPath.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, Comparison);
/// <summary><see cref="IsInside"/>, or the root itself.</summary>
public static bool IsInsideOrRoot(string root, string fullPath) =>
string.Equals(Path.TrimEndingDirectorySeparator(root), Path.TrimEndingDirectorySeparator(fullPath), Comparison)
|| IsInside(root, fullPath);
/// <summary>Workspace-relative path with <c>/</c> separators.</summary>
public static string Relative(string root, string fullPath) => Path.GetRelativePath(root, fullPath).Replace('\\', '/');
/// <summary>
/// The full path of <paramref name="path"/> when both it and its real location (every link resolved, component by
/// component) stay inside <paramref name="root"/>. The root itself counts as inside, so the caller can report it as a
/// directory. Throws <c>path escapes the workspace: …</c> otherwise.
/// </summary>
public static string Confine(string root, string path)
{
var full = Paths.Resolve(root, path);
if (!IsInsideOrRoot(root, full)) throw Escapes(path);
// The OS opens `full` by resolving each component in turn, links included, so check where that really lands.
if (RealPath(root) is not { } realRoot || RealPath(full) is not { } real || !IsInsideOrRoot(realRoot, real))
throw Escapes(path);
return full;
}
/// <summary>
/// <c>realpath(3)</c> for a full path that may not exist: walks it from the filesystem root and replaces each link by
/// its target as it is met, so a <c>..</c> inside a link target applies to the real parent, and links inside the
/// target are followed too. Null when resolving takes more than <see cref="MaxLinkHops"/> links (a loop).
/// </summary>
internal static string? RealPath(string fullPath)
{
var current = Path.GetPathRoot(fullPath) ?? "";
var pending = new Stack<string>(); // components still to walk, the next one on top
Push(pending, fullPath[current.Length..]);
var hops = 0;
while (pending.Count > 0)
{
var part = pending.Pop();
if (part is "" or ".") continue;
if (part == "..")
{
current = Path.GetDirectoryName(current) ?? current; // current is real, so its parent is too
continue;
}
var next = Path.Join(current, part);
if (LinkTarget(next) is not { } target)
{
current = next;
continue;
}
if (++hops > MaxLinkHops) return null;
if (Path.IsPathFullyQualified(target))
{
current = Path.GetPathRoot(target)!;
Push(pending, target[current.Length..]);
}
else
{
Push(pending, target); // relative to the link's directory, which is `current`
}
}
return current;
}
private static void Push(Stack<string> pending, string relative)
{
var parts = relative.Split(['/', Path.DirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
for (var i = parts.Length - 1; i >= 0; i--) pending.Push(parts[i]);
}
private static string? LinkTarget(string path)
{
try
{
FileSystemInfo info = Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path);
return info.LinkTarget;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
private static InvalidOperationException Escapes(string path) => new($"path escapes the workspace: {path}");
}

View File

@@ -8,5 +8,8 @@ public static class BuiltInTools
public static readonly IReadOnlyList<string> DefaultActive = ["read", "bash", "edit", "write"];
public static readonly IReadOnlyList<string> ReadOnlySet = ["read", "grep", "find", "ls"];
/// <summary>The <c>send</c> tool, registered only for sessions that have a chat channel.</summary>
public static ITool Send() => new SendTool();
public static IReadOnlyList<ITool> All() => [new ReadTool(), new BashTool(), new EditTool(), new WriteTool(), new LsTool(), new GrepTool(), new FindTool()];
}

View File

@@ -190,6 +190,19 @@ public class McpExtensionTests
Assert.Contains("call `send` with that", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task Browser_section_does_not_mention_an_inactive_send()
{
await using var host = new McpTestHost(PresetOnFake());
host.Faux.Enqueue(FauxTurn.Say("ok"));
var session = await host.CreateSessionAsync(channel: new NullChannel());
session.SetActiveTools(session.ActiveTools.Where(t => t != "send").ToList());
await session.PromptAsync("hi");
Assert.Contains("## Browser", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
Assert.DoesNotContain("`send`", host.Faux.Requests[0].SystemPrompt, StringComparison.Ordinal);
}
[Fact]
public async Task Browser_section_mentions_send_only_when_a_send_tool_exists()
{

View File

@@ -112,14 +112,45 @@ public sealed class AttachmentChatTests
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = [9, 8, 7];
host.Bot.Files["doc"] = Png;
host.Faux.Enqueue(FauxTurn.Say("A diagram."));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", Png.Length));
var image = Assert.Single(Prompt(host).Content.OfType<ImageContent>());
Assert.Equal(("image/png", Convert.ToBase64String(Png)), (image.MimeType, image.Data));
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 10 bytes)]", Prompt(host).Text, StringComparison.Ordinal);
}
private static readonly byte[] Png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2];
[Fact]
public async Task An_image_document_is_attached_with_the_type_its_bytes_show()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 1, 2];
host.Bot.Files["doc"] = jpeg;
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("photo.png", "image/png", jpeg.Length));
Assert.Equal("image/jpeg", Assert.Single(Prompt(host).Content.OfType<ImageContent>()).MimeType);
}
[Fact]
public async Task An_image_document_whose_bytes_are_no_known_image_is_path_only()
{
var time = new FakeTimeProvider(Today);
await using var host = new ChatHost(time: time);
host.Bot.Files["doc"] = [9, 8, 7]; // e.g. HEIC or a truncated file labelled image/png
host.Faux.Enqueue(FauxTurn.Say("ok"));
await RouteAndFlushAsync(host, time, Updates.DocumentMessage("diagram.png", "image/png", 3));
var image = Assert.Single(Prompt(host).Content.OfType<ImageContent>());
Assert.Equal(("image/png", Convert.ToBase64String([9, 8, 7])), (image.MimeType, image.Data));
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes)]", Prompt(host).Text, StringComparison.Ordinal);
Assert.Empty(Prompt(host).Content.OfType<ImageContent>());
Assert.EndsWith("[File received: inbox/2026-09-26-diagram.png (image/png, 3 bytes) (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)]",
Prompt(host).Text, StringComparison.Ordinal);
}
[Fact]

View File

@@ -52,6 +52,57 @@ public sealed class ChatRendererOrderTests
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => renderer.RunInOrderAsync(_ => Task.FromResult(1)));
}
[Fact]
public async Task RunInOrderAsync_cancelled_while_queued_returns_at_once_and_skips_the_work()
{
var (_, _, renderer) = Setup();
await using var _ = renderer;
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
var slow = renderer.RunInOrderAsync(ct => gate.Task.WaitAsync(ct)); // e.g. a big upload with retries
var ran = false;
using var cts = new CancellationTokenSource();
var queued = renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(2); }, cts.Token);
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queued.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.False(slow.IsCompleted);
gate.SetResult(1);
Assert.Equal(1, await slow.WaitAsync(TimeSpan.FromSeconds(5)));
await renderer.RunInOrderAsync(_ => Task.FromResult(0)).WaitAsync(TimeSpan.FromSeconds(5)); // the loop moved past it
Assert.False(ran);
}
[Fact]
public async Task RunInOrderAsync_with_a_cancelled_token_never_queues()
{
var (_, _, renderer) = Setup();
await using var _ = renderer;
var ran = false;
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(1); }, new CancellationToken(canceled: true)));
Assert.False(ran);
}
[Fact]
public async Task A_work_item_still_queued_when_disposal_gives_up_waiting_is_cancelled()
{
var (_, _, renderer) = Setup();
var stuck = renderer.RunInOrderAsync(async ct =>
{
await Task.Delay(Timeout.Infinite, ct);
return 1;
});
var ran = false;
var queued = renderer.RunInOrderAsync(_ => { ran = true; return Task.FromResult(2); });
await renderer.DisposeAsync(); // drains for up to 5 s, then cancels the loop
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => queued.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.False(ran);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stuck);
}
[Fact]
public async Task SayAsync_sends_plain_text_cut_to_4096_characters()
{

View File

@@ -92,4 +92,30 @@ public sealed class OpenAiSpeechToTextTests
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: the response has no text.", ex.Message);
}
private sealed class HangingHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
throw new InvalidOperationException("unreachable");
}
}
[Fact]
public async Task A_client_timeout_is_a_speech_failure_not_a_cancellation()
{
var http = new HttpClient(new HangingHandler()) { Timeout = TimeSpan.FromMilliseconds(50) };
var stt = new OpenAiSpeechToText(http, new SpeechOptions { ApiKey = Key });
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: the request timed out.", ex.Message);
}
[Fact]
public async Task The_callers_cancellation_still_cancels()
{
var stt = new OpenAiSpeechToText(new HttpClient(new HangingHandler()), new SpeechOptions { ApiKey = Key });
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", cts.Token));
}
}

View File

@@ -1,7 +1,9 @@
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Tests.Support;
@@ -103,4 +105,48 @@ public sealed class TelegramChatChannelTests
Assert.Equal("Telegram refused the file: Bad Request: PHOTO_INVALID_DIMENSIONS", ex.Message);
Assert.Empty(host.Bot.Uploads);
}
private static (FakeBotClient Bot, TelegramChatChannel Channel, ChatRenderer Renderer) Direct()
{
var bot = new FakeBotClient();
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance) { FileRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] };
var options = new TelegramOptions { EditInterval = TimeSpan.FromMilliseconds(20), TypingInterval = TimeSpan.FromSeconds(10) };
var renderer = new ChatRenderer(1001, sender, options, NullLogger.Instance) { ReplyRetryDelays = [TimeSpan.Zero, TimeSpan.Zero] };
return (bot, new TelegramChatChannel(1001, sender, renderer), renderer);
}
[Fact]
public async Task SendTextAsync_throws_when_a_chunk_still_fails_after_its_retries_but_sends_the_others()
{
var (bot, channel, renderer) = Direct();
await using var _ = renderer;
var markdown = new string('a', 3000) + "\n\n" + new string('b', 3000); // two chunks
bot.OnSend = r => { if (r is SendMessageRequest m && m.Text.StartsWith('a')) throw FakeBotClient.NetworkDown(); };
var ex = await Assert.ThrowsAsync<ChatDeliveryException>(() => channel.SendTextAsync(markdown, CancellationToken.None));
Assert.Equal("Telegram did not accept the message.", ex.Message);
Assert.Equal(3, bot.Sent.Count(m => m.Text.StartsWith('a'))); // the first try and two retries
Assert.Single(bot.CurrentTexts.Values, t => t.StartsWith('b'));
}
[Fact]
public async Task SendFileAsync_throws_after_three_attempts_when_the_network_stays_down()
{
var (bot, channel, renderer) = Direct();
await using var _ = renderer;
var path = Path.Combine(Path.GetTempPath(), $"volcanic-channel-{Guid.NewGuid():N}.pdf");
await File.WriteAllBytesAsync(path, [1, 2, 3]);
try
{
bot.OnSend = r => { if (r is SendDocumentRequest) throw FakeBotClient.NetworkDown(); };
var ex = await Assert.ThrowsAsync<ChatDeliveryException>(() => channel.SendFileAsync(new ChatFile(path), CancellationToken.None));
Assert.StartsWith("Telegram refused the file: ", ex.Message, StringComparison.Ordinal);
Assert.Equal(3, bot.Requests.OfType<SendDocumentRequest>().Count());
}
finally
{
File.Delete(path);
}
}
}

View File

@@ -85,6 +85,37 @@ public sealed class VoiceChatTests
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task A_transcription_timeout_gets_a_notice_and_no_run()
{
// HttpClient.Timeout surfaces as a TaskCanceledException although nobody cancelled the message.
var stt = new FakeSpeechToText { Failure = new TaskCanceledException("timeout", new TimeoutException()) };
await using var host = new ChatHost(speech: stt);
host.Bot.Files["voice"] = [1];
await host.RouteAsync(Updates.VoiceMessage());
await host.SettleAsync();
Assert.Contains("Could not transcribe the voice message: timeout", host.Texts);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task An_audio_transcription_timeout_still_prompts_the_saved_file()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
var stt = new FakeSpeechToText { Failure = new TaskCanceledException("timeout", new TimeoutException()) };
await using var host = new ChatHost(time: time, speech: stt);
host.Bot.Files["audio"] = [4, 5, 6];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
await MessageContextChatTests.FlushAsync(host, time, 1);
await host.SettleAsync();
Assert.Contains("Could not transcribe the voice message: timeout", host.Texts);
Assert.EndsWith("[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]", Prompt(host), StringComparison.Ordinal);
}
[Fact]
public async Task An_empty_transcript_gets_a_notice_and_no_run()
{
@@ -140,12 +171,22 @@ public sealed class VoiceChatTests
await host.SettleAsync();
Assert.EndsWith("\n[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]\n\n[Voice message] welcome to the show", Prompt(host), StringComparison.Ordinal);
Assert.Equal(("talk.mp3", "audio/mpeg"), (stt.Calls.Single().FileName, stt.Calls.Single().MimeType));
Assert.Equal(("audio.mp3", "audio/mpeg"), (stt.Calls.Single().FileName, stt.Calls.Single().MimeType));
Assert.Equal(new byte[] { 4, 5, 6 }, stt.Calls.Single().Audio);
Assert.True(File.Exists(Path.Combine(host.WorkspaceOf(), "inbox", "2026-09-26-talk.mp3")));
Assert.Single(host.Bot.Requests.OfType<GetFileRequest>()); // downloaded once, transcribed from the saved file
}
[Theory]
[InlineData("Песня.mp3", "audio/mpeg", "audio.mp3")]
[InlineData("talk.M4A", "audio/mp4", "audio.m4a")]
[InlineData(null, "audio/mp4", "audio.m4a")]
[InlineData(null, "audio/ogg", "audio.ogg")]
[InlineData("memo.oga", "audio/ogg", "audio.ogg")]
[InlineData("noext", null, "audio.mp3")]
public void Audio_goes_to_speech_to_text_under_an_ascii_name_with_a_known_extension(string? fileName, string? mime, string expected) =>
Assert.Equal(expected, Volcanic.AgenticCore.Telegram.Inbound.InboundMedia.SpeechFileName(fileName, mime));
[Fact]
public async Task Without_speech_to_text_an_audio_file_is_still_saved_and_prompted()
{
@@ -165,13 +206,22 @@ public sealed class VoiceChatTests
[Fact]
public async Task A_voice_note_and_a_text_right_after_it_are_one_prompt_in_order()
{
var stt = new FakeSpeechToText("first the voice") { Delay = TimeSpan.FromMilliseconds(300) }; // well past the 20 ms window
await using var host = new ChatHost(speech: stt);
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
var stt = new FakeSpeechToText("first the voice") { Gate = new TaskCompletionSource() };
await using var host = new ChatHost(time: time, speech: stt);
host.Bot.Files["voice"] = [1];
host.Faux.Enqueue(FauxTurn.Say("ok"));
await host.RouteAsync(Updates.VoiceMessage());
await host.SendAsync("then the text");
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => stt.Calls.Count == 1 && chat.Inbox.Buffered == 1 && chat.Inbox.OpenLeases == 1, "the text composed, the voice held");
time.Advance(host.Options.InputDebounce * 10); // the debounce window elapses while the voice lease is still open
await Task.Delay(50);
Assert.Empty(host.Faux.Requests);
stt.Gate.SetResult();
await MessageContextChatTests.FlushAsync(host, time, 2);
await host.SettleAsync();
var prompt = Prompt(host);

View File

@@ -163,6 +163,50 @@ public sealed class SendToolTests : IDisposable
Assert.Single(_channel.Files);
}
[Fact]
public async Task A_link_whose_target_runs_through_a_directory_link_pointing_outside_fails()
{
if (OperatingSystem.IsWindows()) return;
var outsideDir = Path.Combine(_root, "etc");
Directory.CreateDirectory(outsideDir);
System.IO.File.WriteAllText(Path.Combine(outsideDir, "secret"), "key");
Directory.CreateSymbolicLink(Path.Combine(_ws, "d"), outsideDir);
Directory.CreateDirectory(Path.Combine(_ws, "a"));
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "a", "l"), "../d/secret"); // textually ws/d/secret
Assert.Equal("path escapes the workspace: a/l", await Fails(new SendArgs("a/l")));
}
[Fact]
public async Task A_dotdot_inside_a_link_target_applies_to_the_real_parent()
{
if (OperatingSystem.IsWindows()) return;
var deep = Path.Combine(_root, "outside", "deep");
Directory.CreateDirectory(deep);
System.IO.File.WriteAllText(Path.Combine(_root, "outside", "secret"), "key");
Directory.CreateSymbolicLink(Path.Combine(_ws, "x"), deep);
File("secret"); // the decoy the textual reading would pick
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "l"), "x/../secret"); // really <root>/outside/secret
Assert.Equal("path escapes the workspace: l", await Fails(new SendArgs("l")));
}
[Fact]
public async Task A_link_loop_fails()
{
if (OperatingSystem.IsWindows()) return;
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "a"), "b");
System.IO.File.CreateSymbolicLink(Path.Combine(_ws, "b"), "a");
Assert.Equal("path escapes the workspace: a", await Fails(new SendArgs("a")));
}
[Theory]
[InlineData(".")]
[InlineData("ROOT")]
public async Task The_workspace_itself_is_a_directory(string path)
{
if (path == "ROOT") path = _ws;
Assert.Equal($"path is a directory, not a file: {path}", await Fails(new SendArgs(path)));
}
[Fact]
public async Task Missing_file_fails() =>
Assert.Equal("file not found: nope.pdf", await Fails(new SendArgs("nope.pdf")));

View File

@@ -16,6 +16,7 @@ public sealed class StubExtensionHost : IExtensionHost
public string SessionId => "session-1";
public string Scope { get; init; } = "test-scope";
public IUserInterface Ui { get; init; } = NullUserInterface.Instance;
public IChatChannel? Channel { get; init; }
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry { get; init; } = new ModelRegistry(new ApiKeyResolver(null, _ => null));
public Model? Model { get; set; }