forked from admin/VolcanicAgenticCore
test(telegram): an expired session in another chat is logged in from the owner chat and resumes
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user