test(telegram): an expired session in another chat is logged in from the owner chat and resumes

This commit is contained in:
EugeneTes
2026-09-25 17:57:41 +00:00
parent bbece220c8
commit f2027c9fde
2 changed files with 139 additions and 5 deletions

View File

@@ -0,0 +1,128 @@
using Microsoft.Extensions.DependencyInjection;
using Telegram.Bot.Types.ReplyMarkups;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>
/// The whole re-login path through the bot: the real Claude Code extension (built per session, as in Program.cs) runs a
/// fake <c>claude</c> script whose session has expired, and the login goes to the owner's chat through
/// <see cref="OwnerLoginChannel"/> while the request came from another allowed user's chat.
/// </summary>
[Collection(FakeScripts.Collection)]
public sealed class ClaudeCodeReloginTests : IDisposable
{
private const long Requester = 2002;
private const string Init = """{"type":"system","subtype":"init","cwd":"/project","session_id":"s","tools":[],"mcp_servers":[],"model":"claude-sonnet-5","permissionMode":"dontAsk","slash_commands":[],"apiKeySource":"none","skills":[],"plugins":[]}""";
private readonly string _root = Directory.CreateTempSubdirectory("telegram-relogin-test-").FullName;
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
/// <summary>
/// A fake claude: <c>-p</c> runs report an expired session until <c>auth login</c> has read the code "good", then
/// answer "hello there"; <c>auth status</c> reflects the login.
/// </summary>
private string FakeClaude()
{
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
var expired = Path.Combine(_root, "expired.jsonl");
File.WriteAllLines(expired, [
Init,
"""{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"result":"Failed to authenticate. API Error: 401 OAuth token has expired.","usage":{"input_tokens":0,"output_tokens":0}}""",
]);
var ok = Path.Combine(_root, "ok.jsonl");
File.WriteAllLines(ok, [
Init,
"""{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_t","model":"claude-sonnet-5","usage":{"input_tokens":7}}}}""",
"""{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}""",
"""{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello there"}}}""",
"""{"type":"stream_event","event":{"type":"content_block_stop","index":0}}""",
"""{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}}""",
"""{"type":"stream_event","event":{"type":"message_stop"}}""",
"""{"type":"result","subtype":"success","is_error":false,"result":"hello there","stop_reason":"end_turn","usage":{"input_tokens":7,"output_tokens":3}}""",
]);
var script = Path.Combine(_root, "claude");
File.WriteAllText(script, $$"""
#!/bin/sh
STATE="{{state}}"
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
echo "If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?state=owner"
printf "Paste code here if prompted > "
read code
echo "$code" > "$STATE/code"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
if [ "$(cat "$STATE/code" 2>/dev/null)" = "good" ]; then echo '{"loggedIn":true,"authMethod":"claude.ai"}'; else echo '{"loggedIn":false}'; fi
exit 0
fi
echo run >> "$STATE/runs"
if [ "$(cat "$STATE/code" 2>/dev/null)" = "good" ]; then cat "{{ok}}"; else cat "{{expired}}"; fi
exit 0
""".Replace("\r\n", "\n"));
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
[Fact]
public async Task Expired_session_in_another_chat_is_logged_in_from_the_owner_chat_and_the_request_resumes()
{
if (!(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())) return;
var claudePath = FakeClaude();
await using var host = new ChatHost(
(h, services) => services.AddSingleton(new ClaudeCodeProviderOptions
{
ClaudePath = claudePath,
GetEnv = _ => null,
Registry = new SessionRegistry(),
Coordinator = new ClaudeLoginCoordinator(),
LoginUi = ct => new OwnerLoginChannel(h.Chats, h.Options).ResolveAsync(ct),
}),
core =>
{
core.IncludeBuiltInTools = false; // no tools: the fake needs no proposal bridge
core.AddExtension<ClaudeCodeProviderExtension>();
});
host.Options.Model = "claude-code/sonnet";
host.Options.AllowedUserIds.Add(Requester);
await host.SendAsync("hi", userId: Requester);
// The link and the code question reach the owner (the first allowed user), not the requester.
var owner = await host.ChatAsync(Updates.AllowedUser);
await Wait.UntilAsync(() => owner.Ui.IsWaitingForInput, "the login code question in the owner chat", 15000);
var prompt = Assert.Single(host.Bot.Sent, s => s.Text.Contains("oauth/authorize?state=owner", StringComparison.Ordinal));
Assert.Equal(Updates.AllowedUser, prompt.ChatId.Identifier);
Assert.IsType<ForceReplyMarkup>(prompt.ReplyMarkup);
// The owner's plain reply is the code; the requester's run resumes and gets its answer.
await host.SendAsync("good", userId: Updates.AllowedUser);
await host.SettleAsync(Requester);
await (await host.ChatAsync(Updates.AllowedUser)).Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
var requesterTexts = host.Bot.Sent.Where(s => s.ChatId.Identifier == Requester).Select(s => s.Text).ToList();
Assert.Contains(requesterTexts, t => t.Contains("waiting for the bot owner", StringComparison.Ordinal));
Assert.Contains(requesterTexts, t => t.Contains("hello there", StringComparison.Ordinal));
Assert.DoesNotContain(requesterTexts, t => t.Contains("oauth/authorize", StringComparison.Ordinal));
Assert.Contains(host.Bot.Sent, s => s.ChatId.Identifier == Updates.AllowedUser && s.Text.Contains("login complete", StringComparison.Ordinal));
Assert.Equal(2, File.ReadAllLines(Path.Combine(_root, "state", "runs")).Length);
Assert.Empty(host.Faux.Requests);
}
}
/// <summary>
/// Runs apart from every other test: a fork elsewhere while the fake script is still open for writing would make
/// exec'ing it fail with ETXTBSY ("Text file busy").
/// </summary>
[CollectionDefinition(Collection, DisableParallelization = true)]
public sealed class FakeScripts
{
public const string Collection = "fake-claude-scripts";
}

View File

@@ -11,7 +11,11 @@ using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
/// <summary>The bot's chat side wired to a <see cref="FakeBotClient"/> and the <see cref="FauxProvider"/>.</summary>
/// <summary>
/// The bot's chat side wired to a <see cref="FakeBotClient"/> and the <see cref="FauxProvider"/>; <paramref name="configure"/>
/// adds services and <paramref name="core"/> adjusts the core options (extensions, tools) before the provider is built;
/// either may capture the host, whose <see cref="Chats"/> exist by the time a session runs.
/// </summary>
public sealed class ChatHost : IAsyncDisposable
{
public FakeBotClient Bot { get; } = new();
@@ -22,7 +26,7 @@ public sealed class ChatHost : IAsyncDisposable
private readonly ServiceProvider _services;
private readonly string _root;
public ChatHost()
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null)
{
_root = Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
Options = new TelegramOptions
@@ -36,6 +40,7 @@ public sealed class ChatHost : IAsyncDisposable
};
var store = new InMemorySessionStore();
var services = new ServiceCollection();
configure?.Invoke(this, services);
services.AddAgenticCore(o =>
{
o.AgentDir = Path.Combine(_root, "agent");
@@ -43,6 +48,7 @@ public sealed class ChatHost : IAsyncDisposable
o.DefaultModel = FauxProvider.ModelKey;
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
o.SessionStoreFactory = _ => store;
core?.Invoke(o);
});
_services = services.BuildServiceProvider();
ILoggerFactory loggers = NullLoggerFactory.Instance;
@@ -53,12 +59,12 @@ public sealed class ChatHost : IAsyncDisposable
public Task SendAsync(string text, long userId = Updates.AllowedUser) => Router.RouteAsync(Updates.Text(text, userId), CancellationToken.None);
public async Task<ChatContext> ChatAsync() => await Chats.GetAsync(Updates.AllowedUser);
public async Task<ChatContext> ChatAsync(long chatId = Updates.AllowedUser) => await Chats.GetAsync(chatId);
/// <summary>Wait until the chat's run has settled and everything it produced is rendered.</summary>
public async Task SettleAsync()
public async Task SettleAsync(long chatId = Updates.AllowedUser)
{
var chat = await ChatAsync();
var chat = await ChatAsync(chatId);
await chat.LastPrompt.WaitAsync(TimeSpan.FromSeconds(10));
await Wait.UntilAsync(() => !chat.Session.IsStreaming, "the run to settle");
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));