feat(claude-code): pause an expired request, log in through the host UI and replay it

This commit is contained in:
EugeneTes
2026-09-25 17:25:43 +00:00
parent 521d391527
commit f90bf862f4
9 changed files with 362 additions and 6 deletions

View File

@@ -30,6 +30,15 @@ public sealed record ClaudeCodeApiOptions
/// <summary>The MCP server name the bridge is registered under (tools become <c>mcp__&lt;name&gt;__&lt;alias&gt;</c>).</summary>
public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
/// <summary>Where the login link goes when the session expires; null disables the automatic login (headless hosts).</summary>
public Func<CancellationToken, Task<IUserInterface?>>? LoginUi { get; init; }
/// <summary>The single-flight login coordinator; <see cref="ClaudeLoginCoordinator.Shared"/> when null.</summary>
public ClaudeLoginCoordinator? Coordinator { get; init; }
/// <summary>Timeouts and retry budget for the automatic login; the defaults when null.</summary>
public ClaudeLoginOptions? LoginOptions { get; init; }
}
/// <summary>
@@ -41,6 +50,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
{
public const string McpReadyTimeoutVariable = "VOLCANIC_CLAUDE_CODE_MCP_READY_TIMEOUT_MS";
public static readonly TimeSpan DefaultMcpReadyTimeout = TimeSpan.FromSeconds(5);
public const string HeadlessLoginMessage = "Claude Code session expired; run \"claude auth login\" on the host (or /claude-code-login from a chat UI)";
private static readonly TimeSpan ReadyPollInterval = TimeSpan.FromMilliseconds(25);
private static readonly TimeSpan ExitGrace = TimeSpan.FromSeconds(10);
private static readonly IReadOnlyDictionary<string, string> NoHeaders = new Dictionary<string, string>();
@@ -73,6 +83,75 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
}
private async Task RunRequestAsync(Model model, LlmContext context, StreamOptions streamOptions, ChannelWriter<AssistantMessageEvent> writer, CancellationToken ct)
{
try
{
var loginUi = Options.LoginUi;
var expired = await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: loginUi is not null, ct).ConfigureAwait(false);
if (expired is null || loginUi is null) return;
// The session expired before anything was published: pause, let the owner log in, then replay exactly once.
var builder = new AssistantMessageBuilder(model);
Notify(streamOptions.SessionId, "Claude Code session expired; waiting for the bot owner to sign in…", NotifyKind.Warning);
LoginOutcome outcome;
try
{
outcome = await (Options.Coordinator ?? ClaudeLoginCoordinator.Shared)
.EnsureLoggedInAsync(flowCt => RunLoginFlowAsync(loginUi, flowCt), ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
await writer.WriteAsync(builder.Fail("aborted", aborted: true), CancellationToken.None).ConfigureAwait(false);
return;
}
if (!outcome.Success)
{
await writer.WriteAsync(builder.Fail($"[{ClaudeCodeException.Codes.Auth}] Claude Code login failed: {outcome.Error}"), CancellationToken.None).ConfigureAwait(false);
return;
}
await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: false, ct).ConfigureAwait(false);
}
finally
{
writer.TryComplete();
}
}
/// <summary>The login flow the coordinator runs once for every waiting request: resolve the owner, drive the CLI, report.</summary>
public async Task<LoginOutcome> RunLoginFlowAsync(Func<CancellationToken, Task<IUserInterface?>> loginUi, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(loginUi);
if (string.IsNullOrEmpty(Options.ClaudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found");
var ui = await loginUi(ct).ConfigureAwait(false);
if (ui is null) return LoginOutcome.Failed("the owner could not be reached; the first allowed user must start a chat with the bot");
return await LoginThroughAsync(ui, ct).ConfigureAwait(false);
}
/// <summary>Runs the CLI login with <paramref name="ui"/> as the prompt and reports the result there.</summary>
public async Task<LoginOutcome> LoginThroughAsync(IUserInterface ui, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ui);
if (string.IsNullOrEmpty(Options.ClaudePath)) return LoginOutcome.Failed("Claude Code (claude) was not found");
var env = ClaudeArgs.AllowListed(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens: null);
var outcome = await ClaudeLogin.RunAsync(Options.ClaudePath, env, new UiLoginPrompt(ui), Options.LoginOptions, ct).ConfigureAwait(false);
if (outcome.Success) ui.Notify("Claude Code login complete ✓");
else ui.Notify($"Claude Code login failed: {outcome.Error}", NotifyKind.Error);
return outcome;
}
private void Notify(string? sessionId, string message, NotifyKind kind)
{
if (sessionId is null || Registry.Get(sessionId)?.Notify is not { } notify) return;
try { notify(message, kind); } catch (Exception) { /* notifications are advisory */ }
}
/// <summary>
/// One Claude process run. Publishes its events and its terminal event, except that with
/// <paramref name="deferLoginFailure"/> a login-required failure arriving before any content is returned unpublished
/// (the held-back <c>Start</c> is dropped) so the caller can log in and replay.
/// </summary>
private async Task<ClaudeCodeException?> RunAttemptAsync(Model model, LlmContext context, StreamOptions streamOptions,
ChannelWriter<AssistantMessageEvent> writer, bool deferLoginFailure, CancellationToken ct)
{
var builder = new AssistantMessageBuilder(model);
StreamEventMapper? mapper = null;
@@ -82,6 +161,26 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
Action<string, NotifyKind>? notify = null;
var terminatedForHandoff = false;
var responseAnnounced = false;
var wroteAny = false;
AssistantMessageEvent? heldStart = null;
// A deferring attempt holds the init Start back until content follows, so a replay never publishes two Starts.
async ValueTask PublishAsync(AssistantMessageEvent e)
{
if (deferLoginFailure && !wroteAny && heldStart is null && e is AssistantMessageEvent.Start)
{
heldStart = e;
return;
}
if (heldStart is { } held)
{
heldStart = null;
await writer.WriteAsync(held, CancellationToken.None).ConfigureAwait(false);
}
wroteAny = true;
await writer.WriteAsync(e, CancellationToken.None).ConfigureAwait(false);
}
try
{
ct.ThrowIfCancellationRequested();
@@ -173,7 +272,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
responseAnnounced = true;
if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false);
}
foreach (var e in events) await writer.WriteAsync(e, CancellationToken.None).ConfigureAwait(false);
foreach (var e in events) await PublishAsync(e).ConfigureAwait(false);
if (mapper.HandoffLatched && !terminatedForHandoff)
{
// The proposal (or the length stop) is complete; Claude Code would now run a turn of its own.
@@ -204,6 +303,12 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProcessExit, $"Claude Code exited after a successful result (code {exitCode}){StderrDetail(process, tempDir)}");
done = mapper.Complete();
}
else if (mapper.AuthenticationFailed)
{
// Claude Code gave up on an expired session without a result record: still a login-required failure.
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
$"Claude Code session expired: authentication failed before a terminal event (code {exitCode}){StderrDetail(process, tempDir)}", loginRequired: true);
}
else
{
// A rejected rate limit explains the missing terminal better than the exit code (and the core retries it).
@@ -212,16 +317,19 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
}
Cleanup(ref tempDir);
await writer.WriteAsync(done, CancellationToken.None).ConfigureAwait(false);
await PublishAsync(done).ConfigureAwait(false);
return null;
}
catch (Exception ex)
{
var aborted = ct.IsCancellationRequested || ex is OperationCanceledException;
if (process is not null) await process.TerminateAsync().ConfigureAwait(false);
if (deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login) return login;
var failure = builder.IsFinished
? null
: aborted ? builder.Fail("aborted", aborted: true) : builder.Fail(FormatFailure(ex));
if (failure is not null) await writer.WriteAsync(failure, CancellationToken.None).ConfigureAwait(false);
if (failure is not null) await PublishAsync(failure).ConfigureAwait(false);
return null;
}
finally
{
@@ -234,7 +342,6 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
try { notify(notice, NotifyKind.Warning); } catch (Exception) { /* notifications are advisory */ }
}
}
writer.TryComplete();
}
}
@@ -289,8 +396,10 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
return excerpt.Length == 0 ? "" : $": {excerpt}";
}
private static string FormatFailure(Exception ex) => ex switch
private string FormatFailure(Exception ex) => ex switch
{
// Without a login channel, say how to clear the expiry by hand.
ClaudeCodeException { LoginRequired: true } when Options.LoginUi is null => $"[{ClaudeCodeException.Codes.Auth}] {HeadlessLoginMessage}",
ClaudeCodeException claude => claude.FormatForUser(),
_ => $"[provider] {ex.GetType().Name}: {ex.Message}",
};

View File

@@ -16,6 +16,15 @@ public sealed record ClaudeCodeProviderOptions
/// <summary>The registry to use; the process-wide <see cref="SessionRegistry.Shared"/> when null.</summary>
public SessionRegistry? Registry { get; init; }
public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
/// <summary>Where the login link goes when the session expires; null disables the automatic login (headless hosts).</summary>
public Func<CancellationToken, Task<IUserInterface?>>? LoginUi { get; init; }
/// <summary>The single-flight login coordinator; <see cref="ClaudeLoginCoordinator.Shared"/> when null.</summary>
public ClaudeLoginCoordinator? Coordinator { get; init; }
/// <summary>Timeouts and retry budget for the automatic login; the defaults when null.</summary>
public ClaudeLoginOptions? LoginOptions { get; init; }
}
/// <summary>
@@ -48,6 +57,9 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
DotnetPath = _options.DotnetPath ?? BridgeLocator.FindDotnet(getEnv),
GetEnv = getEnv,
ServerName = _options.ServerName,
LoginUi = _options.LoginUi,
Coordinator = _options.Coordinator ?? ClaudeLoginCoordinator.Shared,
LoginOptions = _options.LoginOptions,
};
var claude = new ClaudeCodeApi(registry, apiOptions);
Api = claude;

View File

@@ -78,6 +78,9 @@ public sealed partial class StreamEventMapper
/// <summary>A held tool-argument failure for the provider to report when no recovery signal claimed it.</summary>
public string? DeferredError => _deferredToolArguments;
/// <summary>Claude Code attributed a failure to an expired session (<c>authentication_failed</c>); an exit with no terminal record is then login-required.</summary>
public bool AuthenticationFailed => _authenticationFailed;
/// <summary>The most recent rejected rate limit, used as the failure detail when Claude exits without a terminal event.</summary>
public string? RateLimitFailure => _rateLimitFailure;

View File

@@ -0,0 +1,22 @@
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary><see cref="ILoginPrompt"/> over a host dialog surface: the link as a notification, the code as a free-text answer.</summary>
public sealed class UiLoginPrompt(IUserInterface ui) : ILoginPrompt
{
public const string CodeQuestion = "Paste the Claude login code";
private readonly IUserInterface _ui = ui ?? throw new ArgumentNullException(nameof(ui));
public async Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken cancellationToken)
{
_ui.Notify("Claude Code needs you to sign in again.\n" +
$"1. Open: {url}\n" +
"2. Sign in with the Claude subscription account.\n" +
"3. Paste the code shown on the page as a reply.");
return await _ui.InputAsync(CodeQuestion, "code", null, cancellationToken).ConfigureAwait(false);
}
public void Report(string message) => _ui.Notify(message, NotifyKind.Warning);
}

View File

@@ -3,11 +3,13 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
/// <summary>Drives <see cref="ClaudeCodeApi"/> end to end against a fake <c>claude</c> shell script. No test ever spawns the real CLI.</summary>
[Collection(FakeScripts.Collection)]
public class ClaudeCodeApiTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("claude-api-test-").FullName;
@@ -45,8 +47,10 @@ public class ClaudeCodeApiTests : IDisposable
return script;
}
private ClaudeCodeApi Api(string claudePath) => new(_registry, new ClaudeCodeApiOptions
private ClaudeCodeApi Api(string claudePath, Func<CancellationToken, Task<IUserInterface?>>? loginUi = null, ClaudeLoginCoordinator? coordinator = null) => new(_registry, new ClaudeCodeApiOptions
{
LoginUi = loginUi,
Coordinator = coordinator ?? new ClaudeLoginCoordinator(),
ClaudePath = claudePath,
BridgeDllPath = BridgeDll,
DotnetPath = Environment.ProcessPath ?? "dotnet",
@@ -218,4 +222,151 @@ public class ClaudeCodeApiTests : IDisposable
Assert.StartsWith("[auth]", Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events)).Message.ErrorMessage);
}
private string StatePath(string name) => Path.Combine(_root, "state", name);
private int Runs() => File.Exists(StatePath("runs")) ? File.ReadAllLines(StatePath("runs")).Length : 0;
/// <summary>
/// A fake claude whose subscription session expires: <c>-p</c> runs report the expiry until <c>auth login</c> has
/// accepted a code (and forever when <c>expire-always</c> exists); <c>auth status</c> reflects the login.
/// </summary>
private string ExpiringClaude()
{
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
return FakeClaude(
$"STATE=\"{state}\"\n" +
"if [ \"$1\" = \"auth\" ] && [ \"$2\" = \"login\" ]; then\n" +
" echo \"If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?state=x\"\n" +
" printf \"Paste code here if prompted > \"; read code; : > \"$STATE/logged-in\"; exit 0\n" +
"fi\n" +
"if [ \"$1\" = \"auth\" ] && [ \"$2\" = \"status\" ]; then\n" +
" if [ -f \"$STATE/logged-in\" ]; then echo '{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}'; else echo '{\"loggedIn\":false}'; fi; exit 0\n" +
"fi\n" +
"echo run >> \"$STATE/runs\"\n" +
$"if [ -f \"$STATE/logged-in\" ] && [ ! -f \"$STATE/expire-always\" ]; then cat \"{Fixtures.FixturePath("claude-2.1.281-stream-tool-ok.jsonl")}\"; " +
$"else cat \"{Fixtures.FixturePath("claude-2.1.281-stream-auth-expired.jsonl")}\"; fi",
tail: "exit 0");
}
[Fact]
public async Task Expired_session_logs_in_through_the_login_ui_and_replays()
{
if (!Fixtures.IsPosix) return;
var notes = new List<string>();
_registry.Register("session-1", _cwd, (m, _) => { lock (notes) notes.Add(m); });
var owner = new RecordingUi("the-code");
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(owner));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
// Only the replay is published: one Start, the proposal, and Done.
Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
Assert.Equal(StopReason.ToolUse, Assert.IsType<AssistantMessageEvent.Done>(events[^1]).Reason);
Assert.Equal(2, Runs());
Assert.Contains(notes, n => n.Contains("waiting for the bot owner"));
Assert.Contains(owner.Notes, n => n.Message.Contains("oauth/authorize"));
Assert.Contains(owner.Notes, n => n.Message.Contains("login complete"));
Assert.Equal([UiLoginPrompt.CodeQuestion], owner.Questions);
}
[Fact]
public async Task Expired_again_after_login_is_reported_without_a_loop()
{
if (!Fixtures.IsPosix) return;
var script = ExpiringClaude();
File.WriteAllText(StatePath("expire-always"), "");
var owner = new RecordingUi("code");
var api = Api(script, loginUi: _ => Task.FromResult<IUserInterface?>(owner));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
Assert.Equal(["Start", "Error"], events.Select(e => e.GetType().Name).ToArray());
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
Assert.StartsWith("[auth] Claude Code session expired", error.Message.ErrorMessage);
Assert.Equal(2, Runs());
Assert.Single(owner.Questions);
}
[Fact]
public async Task Without_a_login_ui_the_expiry_is_reported_with_instructions()
{
if (!Fixtures.IsPosix) return;
var api = Api(ExpiringClaude());
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
Assert.StartsWith("[auth]", error.Message.ErrorMessage);
Assert.Contains("claude auth login", error.Message.ErrorMessage);
Assert.Equal(1, Runs());
}
[Fact]
public async Task Unanswered_login_fails_the_request()
{
if (!Fixtures.IsPosix) return;
var owner = new RecordingUi();
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(owner));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
var error = Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events));
Assert.Equal(StopReason.Error, error.Reason);
Assert.Equal("[auth] Claude Code login failed: no code was entered", error.Message.ErrorMessage);
Assert.Equal(1, Runs());
Assert.Contains(owner.Notes, n => n.Kind == NotifyKind.Error && n.Message.Contains("login failed"));
}
[Fact]
public async Task Unreachable_owner_fails_the_request()
{
if (!Fixtures.IsPosix) return;
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(null));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
var error = Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events));
Assert.StartsWith("[auth] Claude Code login failed: the owner could not be reached", error.Message.ErrorMessage);
Assert.Equal(1, Runs());
}
[Fact]
public async Task Cancelling_while_waiting_for_the_login_reports_aborted()
{
if (!Fixtures.IsPosix) return;
using var cts = new CancellationTokenSource();
var asked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var never = new TaskCompletionSource<IUserInterface?>();
var api = Api(ExpiringClaude(), loginUi: _ => { asked.TrySetResult(); return never.Task; });
var collecting = Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, cts.Token));
await asked.Task.WaitAsync(TimeSpan.FromSeconds(10));
cts.Cancel();
var events = await collecting.WaitAsync(TimeSpan.FromSeconds(10));
var error = Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events));
Assert.Equal(StopReason.Aborted, error.Reason);
Assert.Equal("aborted", error.Message.ErrorMessage);
Assert.Equal(1, Runs());
}
[Fact]
public async Task Authentication_failure_without_a_result_is_login_required()
{
if (!Fixtures.IsPosix) return;
var fixture = Path.Combine(_root, "auth-retry.jsonl");
File.WriteAllLines(fixture, [
Fixtures.Init().GetRawText(),
"{\"type\":\"system\",\"subtype\":\"api_retry\",\"error\":\"authentication_failed\",\"error_status\":401}",
]);
var api = Api(FakeClaude($"cat \"{fixture}\"", tail: "exit 1"));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
Assert.StartsWith("[auth]", error.Message.ErrorMessage);
Assert.Contains("claude auth login", error.Message.ErrorMessage);
}
}

View File

@@ -2,6 +2,7 @@ using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
[Collection(FakeScripts.Collection)]
public class ClaudeInstallationTests
{
[Fact]

View File

@@ -2,6 +2,7 @@ using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
[Collection(FakeScripts.Collection)]
public class ClaudeLoginTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("claude-login-test-").FullName;

View File

@@ -40,3 +40,13 @@ internal static class Fixtures
public static bool IsPosix => OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD();
}
/// <summary>
/// Test classes that write fake executables and then run them. Run concurrently, one class's fork can inherit another's
/// still-open write handle to a fresh script, and exec'ing that script then fails with ETXTBSY ("Text file busy").
/// </summary>
[CollectionDefinition(Collection)]
public sealed class FakeScripts
{
public const string Collection = "fake-claude-scripts";
}

View File

@@ -0,0 +1,47 @@
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
/// <summary>A host dialog surface that records notifications and answers free-text questions from a script.</summary>
internal sealed class RecordingUi(params string?[] inputs) : IUserInterface
{
private readonly Lock _lock = new();
private int _next;
public List<(string Message, NotifyKind Kind)> Notes { get; } = [];
public List<string> Questions { get; } = [];
public Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false);
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken)
{
lock (_lock)
{
Questions.Add(title);
return Task.FromResult(_next < inputs.Length ? inputs[_next++] : null);
}
}
public void Notify(string message, NotifyKind kind = NotifyKind.Info) { lock (_lock) Notes.Add((message, kind)); }
public void SetStatus(string key, string? text) { }
}
public class UiLoginPromptTests
{
[Fact]
public async Task Sends_the_link_then_asks_for_the_code()
{
var ui = new RecordingUi("abc");
var code = await new UiLoginPrompt(ui).AskForCodeAsync("https://x/oauth/authorize?s=1", 1, CancellationToken.None);
Assert.Equal("abc", code);
Assert.Contains("https://x/oauth/authorize?s=1", ui.Notes.Single().Message);
Assert.Equal([UiLoginPrompt.CodeQuestion], ui.Questions);
Assert.Equal("Paste the Claude login code", UiLoginPrompt.CodeQuestion);
}
[Fact]
public void Report_is_a_warning()
{
var ui = new RecordingUi();
new UiLoginPrompt(ui).Report("new link");
Assert.Equal([("new link", NotifyKind.Warning)], ui.Notes);
}
}