This commit is contained in:
EugeneTes
2026-09-25 20:27:52 +02:00
40 changed files with 3008 additions and 40 deletions

View File

@@ -68,7 +68,8 @@ await session.PromptAsync("Explain this repository");
- [Providers and models](docs/providers.md): model keys, authentication, the built-in provider extensions.
- [Telegram bot](docs/telegram-bot.md): setup, commands, security, connection recovery.
- [Building a UI (Telegram guide)](docs/ui-integration.md): how to put a chat front end on the core.
- [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor command.
- [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor
command, automatic re-login when the subscription session expires.
- Design spec and plan: `docs/superpowers/specs/`, `docs/superpowers/plans/`.
## Layout of the agent directory
@@ -90,7 +91,11 @@ A project can add `.volcanic/extensions/*.dll` and `.volcanic/settings.json` in
Ported from pi: agent loop with parallel tool execution, steering and follow-up queues, abort, auto-retry,
overflow recovery and threshold compaction; JSONL session trees with resume and branching; the extension
event model; slash commands; provider registration; API key resolution; built-in `read`, `write`, `edit`,
`bash`, `ls`, `grep`, `find` tools; system prompt assembly with `AGENTS.md`/`CLAUDE.md` project context.
`bash`, `ls`, `grep`, `find` tools; system prompt assembly with `AGENTS.md` (or, if absent, `CLAUDE.md`) project context.
Beyond pi: the working directory may also hold `PERSONA.md` (the agent's personality) and `MEMORY.md` (memories
the agent maintains itself with `edit`/`write`); both are appended to the system prompt when present. File names
match case-insensitively.
Not ported (by design): the terminal UI, themes, keybindings, renderers, skills and prompt templates,
OAuth logins, image generation models, session tree navigation commands.

View File

@@ -24,7 +24,8 @@ o.DefaultModel = "claude-code/sonnet"; // or opus, haiku, fable, or the pin
```
Run `/claude-code-doctor` in a session to check the installation, auth, required CLI flags and the bridge
handshake. It costs no quota.
handshake. It costs no quota. If it reports "not logged in", run `/claude-code-login` (see below) or `claude
auth login` on the host.
## How it works
@@ -54,6 +55,39 @@ conversation is replayed each time.
`apiKeySource == "none"` (proof of subscription auth) and a connected `volcanic` MCP server. Anything else
fails closed.
## Session expiry and re-login
A `claude` subscription session can expire mid-use (a 401, or the CLI's own "Please run /login" / "Invalid API
key" wording). The provider detects this from the stream and, given somewhere to send the owner a link, logs
back in without failing the request outright:
1. The failing request pauses instead of erroring, as long as nothing has reached the caller yet (no partial
reply, no tool call).
2. If a `ClaudeCodeProviderOptions.LoginUi` is configured, its `IUserInterface` gets one input prompt whose
title carries the steps and the `claude auth login --claudeai` link and asks for the code shown on the page
("Paste the Claude login code"). Link and prompt are one message, so they can't arrive out of order.
3. `ClaudeLoginCoordinator` keeps this to one flow at a time: concurrent expired requests all wait on the same
login instead of racing the CLI. A request whose `claude` run started before a login completed, but whose
expiry is only reported afterwards, replays straight away instead of starting a second login (the coordinator
counts successful logins in `Generation`).
4. A wrong code gets a fresh link, up to 3 attempts; the owner has 10 minutes to answer each prompt
(`ClaudeLoginOptions.CodeTimeout`), and resolving where the owner is has 30 seconds
(`ClaudeLoginOptions.OwnerTimeout`) before they count as unreachable.
5. On success the paused request replays exactly once; a failure there (including expiring again immediately)
is published as-is, so there is no retry loop.
6. **`LoginUi` is null** (the default, e.g. headless hosts): no login flow runs. The request fails with
`[auth] Claude Code session expired; run "claude auth login" on the host (or /claude-code-login from a chat
UI)`.
Run `/claude-code-login` in any session with a UI to sign in (or switch accounts) through that session's own
dialog, with no owner redirect — useful even before the session has expired. If a login is already running
elsewhere (another chat's expired request), the command waits for it and reports its outcome. Without a UI it
explains that it needs one instead of failing silently.
`ClaudeCodeProviderOptions` carries the pieces a host wires up: `LoginUi` (where the link goes),
`Coordinator` (defaults to the process-wide `ClaudeLoginCoordinator.Shared`), and `LoginOptions` (the timeouts
and attempt budget above). The Telegram host points `LoginUi` at the first `AllowedUserIds` entry's private chat.
## Limitations (inherited from the design)
- Full-history replay costs more context than the Messages API and cannot replay thinking signatures.

View File

@@ -0,0 +1,921 @@
# Claude Code Re-login Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When the `claude` CLI's subscription session expires, the Claude Code provider pauses the request, sends the bot owner a login link through the host UI, takes the pasted code, completes `claude auth login` and replays the request.
**Architecture:** Detection lives in `StreamEventMapper` (a `LoginRequired` flag on `ClaudeCodeException`). A UI-agnostic `ClaudeLogin` drives `claude auth login --claudeai` over pipes through an `ILoginPrompt`. `ClaudeLoginCoordinator` keeps it to one flow per process. `ClaudeCodeApi` retries once after a successful login. The host supplies `ClaudeCodeProviderOptions.LoginUi` (Telegram: the first allowed user's chat).
**Tech Stack:** .NET 10, C#, xUnit, Telegram.Bot. Spec: `docs/superpowers/specs/2026-09-25-claude-code-relogin-design.md`.
**Conventions:** Match surrounding code: file-scoped namespaces, `ConfigureAwait(false)`, XML `<summary>` doc comments on public types, sealed classes/records. Tests that spawn shell scripts return early when `!Fixtures.IsPosix`. Build/test: `dotnet test VolcanicAgenticCore.slnx` from the repo root (filter with `--filter "FullyQualifiedName~<Class>"`).
---
## File map
| File | Change |
|---|---|
| `extensions/…ClaudeCode/ClaudeCodeException.cs` | add `LoginRequired` |
| `extensions/…ClaudeCode/StreamEventMapper.cs` | classify expired-session results |
| `extensions/…ClaudeCode/ClaudeLogin.cs` | **new**: `ILoginPrompt`, `LoginOutcome`, `ClaudeLoginOptions`, `ClaudeLogin` |
| `extensions/…ClaudeCode/ClaudeLoginCoordinator.cs` | **new**: single-flight coordinator |
| `extensions/…ClaudeCode/UiLoginPrompt.cs` | **new**: `ILoginPrompt` over `IUserInterface` |
| `extensions/…ClaudeCode/ClaudeCodeApi.cs` | options `LoginUi`/`Coordinator`/`LoginOptions`; retry after login |
| `extensions/…ClaudeCode/ClaudeCodeProviderExtension.cs` | new options, register `/claude-code-login` |
| `extensions/…ClaudeCode/LoginCommand.cs` | **new**: `/claude-code-login` |
| `extensions/…ClaudeCode/DoctorCommand.cs` | "not logged in" hint |
| `hosts/Volcanic.AgenticCore.Telegram/Program.cs` | register `ClaudeCodeProviderOptions` with `LoginUi` |
| `hosts/Volcanic.AgenticCore.Telegram/Chats/OwnerLoginChannel.cs` | **new**: resolves the owner chat's UI |
| tests under `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/` and `tests/Volcanic.AgenticCore.Telegram.Tests/` | new tests + 2 fixtures |
| `docs/claude-code-provider.md`, `docs/telegram-bot.md` | document the flow |
---
### Task 1: Detect an expired session
**Files:**
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs`
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs` (`AcceptResult` ~line 427, `RejectInterruptedStream` ~line 503, the record dispatch that handles `system/api_retry`)
- Create: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures/claude-2.1.281-stream-auth-expired.jsonl`
- Create: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures/claude-2.1.281-stream-auth-login.jsonl`
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs`
- [ ] **Step 1: Fixtures.** Copy the first line (the `system/init` record) of `claude-2.1.281-stream-http529.jsonl` as the first line of both new files, so the init validation passes with the probe catalogue. Then add:
`claude-2.1.281-stream-auth-expired.jsonl`, after the init line:
```
{"type":"system","subtype":"status","status":"requesting","session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000002"}
{"type":"assistant","message":{"id":"00000000-0000-4000-8000-00000000000a","model":"<synthetic>","role":"assistant","type":"message","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"OAuth token has expired. Please obtain a new token or refresh your existing token.\"}}"}]},"error":"authentication_failed","session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000003"}
{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"duration_ms":312,"num_turns":1,"result":"Failed to authenticate. API Error: 401 OAuth token has expired. Please obtain a new token or refresh your existing token.","stop_reason":"stop_sequence","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"uuid":"00000000-0000-4000-8000-000000000004"}
```
`claude-2.1.281-stream-auth-login.jsonl`, after the init line:
```
{"type":"assistant","message":{"id":"00000000-0000-4000-8000-00000000000a","model":"<synthetic>","role":"assistant","type":"message","content":[{"type":"text","text":"Invalid API key · Please run /login"}]},"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000003"}
{"type":"result","subtype":"success","is_error":true,"duration_ms":120,"num_turns":1,"result":"Invalid API key · Please run /login","stop_reason":"stop_sequence","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"uuid":"00000000-0000-4000-8000-000000000004"}
```
Add a line for both to `Fixtures/README.md` saying they're hand-written from Claude's documented 401 output. Check the test csproj copies `Fixtures/*.jsonl` by glob (it already does for the others; if it lists files, add these).
- [ ] **Step 2: Failing tests.** Find how the existing `StreamEventMapperTests` drive a whole fixture: look at the test for `http529` or `prompt-too-long`, and the helper that feeds `Fixtures.StreamRecords(scenario)` into `new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog).Accept(...)`. Reuse that helper. Add:
```csharp
[Theory]
[InlineData("auth-expired")]
[InlineData("auth-login")]
public void Expired_session_is_a_login_required_auth_failure(string scenario)
{
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
var ex = Assert.Throws<ClaudeCodeException>(() => { foreach (var r in Fixtures.StreamRecords(scenario)) mapper.Accept(r); });
Assert.Equal(ClaudeCodeException.Codes.Auth, ex.Code);
Assert.True(ex.LoginRequired);
Assert.StartsWith("Claude Code session expired", ex.Message);
}
[Fact]
public void Other_error_results_do_not_require_login()
{
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
var ex = Assert.Throws<ClaudeCodeException>(() => { foreach (var r in Fixtures.StreamRecords("sse-error")) mapper.Accept(r); });
Assert.False(ex.LoginRequired);
}
[Fact]
public void Api_key_source_failure_does_not_require_login()
{
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
var ex = Assert.Throws<ClaudeCodeException>(() => mapper.Accept(Fixtures.ProbeInit(apiKeySource: "ANTHROPIC_API_KEY")));
Assert.Equal(ClaudeCodeException.Codes.Auth, ex.Code);
Assert.False(ex.LoginRequired);
}
```
If the `sse-error` fixture doesn't end in an exception from `Accept` (check its existing test), use whichever existing fixture whose test asserts a `protocol_stream` throw.
- [ ] **Step 3: Run it; expect a compile failure (`LoginRequired` missing).** `dotnet test VolcanicAgenticCore.slnx --filter "FullyQualifiedName~StreamEventMapperTests"`
- [ ] **Step 4: Implement.** `ClaudeCodeException`:
```csharp
public sealed class ClaudeCodeException(string code, string message, bool retryable = false, bool loginRequired = false) : Exception(message)
{
...
/// <summary>True when the CLI's subscription session expired and <c>claude auth login</c> would clear the failure.</summary>
public bool LoginRequired { get; } = loginRequired;
```
`StreamEventMapper`:
```csharp
private static readonly string[] LoginPhrases =
["OAuth token has expired", "Please run /login", "Invalid API key", "authentication_failed", "Not logged in"];
private bool _authenticationFailed;
/// <summary>Whether an error result's detail (or status) says the subscription session is gone.</summary>
public static bool IsLoginRequired(int? status, string? detail) =>
status == 401 || (detail is not null && LoginPhrases.Any(p => detail.Contains(p, StringComparison.OrdinalIgnoreCase)));
```
- Where `system/api_retry` records are handled (before `RejectInterruptedStream`'s early return), set `_authenticationFailed = true` when `Str(record, "error") == "authentication_failed"`. In `AcceptAssistant`, also set it when `Str(record, "error") == "authentication_failed"`.
- In `AcceptResult`'s `isError` branch, after `detail` is computed and before the overflow check:
```csharp
int? statusCode = record.TryGetProperty("api_error_status", out var s) && s.ValueKind == JsonValueKind.Number && s.TryGetInt32(out var code) ? code : null;
if (_authenticationFailed || IsLoginRequired(statusCode, detail))
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth, $"Claude Code session expired: {Truncate(detail, 512)}", loginRequired: true);
```
(`Truncate` already exists in the mapper; if its signature differs, adapt.) `ThrowDeferredFailure()` runs before this check. If a deferred rate-limit failure could mask a 401, move the login check above `ThrowDeferredFailure()`.
- [ ] **Step 5: Run the mapper tests, then the full extension test project; all pass.**
- [ ] **Step 6: Commit** `git commit -m "feat(claude-code): classify an expired subscription session as login-required auth failure"`
---
### Task 2: `ClaudeLogin` — drive `claude auth login` over pipes
**Files:**
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeLogin.cs`
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeLoginTests.cs`
The real CLI prints (verified on 2.1.280):
```
Opening browser to sign in…
If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&client_id=…&state=…
Paste code here if prompted >
```
The prompt has **no trailing newline**, so read stdout in chunks, not by line. Then it reads the code line from stdin and exits.
- [ ] **Step 1: Failing tests.** The fake `claude` script handles `auth login`, which prints a URL containing an attempt counter, reads one line and writes it to `$STATE/code`, exiting 0. It also handles `auth status`, which prints `loggedIn: true` only if the stored code equals `good`.
```csharp
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
public class ClaudeLoginTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("claude-login-test-").FullName;
public void Dispose() { try { Directory.Delete(_root, true); } catch (IOException) { } }
private sealed class ScriptedPrompt(params string?[] answers) : ILoginPrompt
{
private int _next;
public List<string> Urls { get; } = [];
public List<string> Reports { get; } = [];
public Func<CancellationToken, Task>? BeforeAnswer { get; init; }
public async Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken ct)
{
Urls.Add(url);
if (BeforeAnswer is not null) await BeforeAnswer(ct);
return _next < answers.Length ? answers[_next++] : null;
}
public void Report(string message) => Reports.Add(message);
}
/// <summary>printUrl=false simulates a CLI that exits without printing a login link.</summary>
private string FakeClaude(bool printUrl = true)
{
var script = Path.Combine(_root, "claude");
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
File.WriteAllText(script, $$"""
#!/bin/sh
STATE="{{state}}"
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
n=$(cat "$STATE/n" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "$STATE/n"
echo "Opening browser to sign in…"
{{(printUrl ? "echo \"If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&state=s$n\"" : "exit 3")}}
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","apiProvider":"firstParty"}'
else
echo '{"loggedIn":false}'
fi
exit 0
fi
exit 1
""".Replace("\r\n", "\n"));
File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
private static IReadOnlyDictionary<string, string> Env() =>
new Dictionary<string, string> { ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin" };
private static readonly ClaudeLoginOptions Fast = new() { UrlTimeout = TimeSpan.FromSeconds(5), ExchangeTimeout = TimeSpan.FromSeconds(5) };
[Fact]
public async Task Valid_code_logs_in()
{
if (!Fixtures.IsPosix) return;
var prompt = new ScriptedPrompt(" good ");
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, CancellationToken.None);
Assert.True(outcome.Success, outcome.Error);
Assert.Equal(["https://claude.com/cai/oauth/authorize?code=true&state=s1"], prompt.Urls);
}
[Fact]
public async Task Rejected_code_gets_a_new_link()
{
if (!Fixtures.IsPosix) return;
var prompt = new ScriptedPrompt("bad", "good");
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, CancellationToken.None);
Assert.True(outcome.Success, outcome.Error);
Assert.Equal(2, prompt.Urls.Count);
Assert.EndsWith("state=s2", prompt.Urls[1]);
Assert.Single(prompt.Reports);
}
[Fact]
public async Task Three_rejected_codes_fail()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), new ScriptedPrompt("a", "b", "c", "good"), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal("the code was rejected 3 times", outcome.Error);
}
[Fact]
public async Task No_answer_fails()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), new ScriptedPrompt(), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal("no code was entered", outcome.Error);
}
[Fact]
public async Task Missing_url_fails()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(printUrl: false), Env(), new ScriptedPrompt("good"), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.StartsWith("claude auth login printed no login URL", outcome.Error);
}
[Fact]
public async Task Cancellation_throws()
{
if (!Fixtures.IsPosix) return;
using var cts = new CancellationTokenSource();
var prompt = new ScriptedPrompt("good") { BeforeAnswer = async ct => { cts.Cancel(); await Task.Delay(Timeout.Infinite, ct); } };
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, cts.Token));
}
}
```
`Fixtures.IsPosix` already exists (the other tests use it).
- [ ] **Step 2: Run; expect a compile failure.**
- [ ] **Step 3: Implement `ClaudeLogin.cs`.** Use `System.Diagnostics.Process` directly (not `ClaudeProcess`, which is built for the JSONL stream and closes stdin after one write; here a free-text read is needed). Mirror `ClaudeInstallation.RunAsync` for process start and kill.
```csharp
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>Where the login link goes and where the pasted code comes from.</summary>
public interface ILoginPrompt
{
/// <summary>Show the link and wait for the code. Null when there is no answer (timeout, cancelled dialog, unreachable user).</summary>
Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken cancellationToken);
/// <summary>A progress or retry message for the person logging in.</summary>
void Report(string message);
}
public sealed record LoginOutcome(bool Success, string? Error)
{
public static readonly LoginOutcome Ok = new(true, null);
public static LoginOutcome Failed(string error) => new(false, error);
}
public sealed record ClaudeLoginOptions
{
public int MaxAttempts { get; init; } = 3;
public TimeSpan UrlTimeout { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan ExchangeTimeout { get; init; } = TimeSpan.FromSeconds(60);
public TimeSpan StatusTimeout { get; init; } = TimeSpan.FromSeconds(10);
}
/// <summary>
/// Runs <c>claude auth login --claudeai</c> headlessly: it prints the authorize URL and waits for the code on stdin.
/// Each attempt is a fresh process (its own PKCE state), so a rejected code gets a new link. Success is confirmed by
/// <c>claude auth status</c>. The URL and code never appear in errors.
/// </summary>
public static partial class ClaudeLogin
{
public static async Task<LoginOutcome> RunAsync(string claudePath, IReadOnlyDictionary<string, string> environment, ILoginPrompt prompt,
ClaudeLoginOptions? options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(prompt);
options ??= new ClaudeLoginOptions();
var env = new Dictionary<string, string>(environment, StringComparer.Ordinal) { ["BROWSER"] = OperatingSystem.IsWindows() ? "none" : "/bin/false" };
for (var attempt = 1; attempt <= options.MaxAttempts; attempt++)
{
var result = await AttemptAsync(claudePath, env, prompt, attempt, options, cancellationToken).ConfigureAwait(false);
if (result is not null) return result;
if (attempt < options.MaxAttempts) prompt.Report("That code was not accepted; here is a new link.");
}
return LoginOutcome.Failed($"the code was rejected {options.MaxAttempts} times");
}
/// <summary>A final outcome, or null when the code was rejected and another attempt may follow.</summary>
private static async Task<LoginOutcome?> AttemptAsync(string claudePath, Dictionary<string, string> env, ILoginPrompt prompt, int attempt,
ClaudeLoginOptions options, CancellationToken ct)
{
var psi = new ProcessStartInfo(claudePath)
{
UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true,
};
psi.ArgumentList.Add("auth"); psi.ArgumentList.Add("login"); psi.ArgumentList.Add("--claudeai");
psi.Environment.Clear();
foreach (var (name, value) in env) psi.Environment[name] = value;
using var process = new Process { StartInfo = psi };
try { process.Start(); }
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException)
{
return LoginOutcome.Failed($"claude auth login could not be started: {ex.Message}");
}
var stderr = new StringBuilder();
var stderrPump = PumpAsync(process.StandardError, stderr);
try
{
var url = await ReadUrlAsync(process, options.UrlTimeout, ct).ConfigureAwait(false);
if (url is null)
{
Kill(process);
return LoginOutcome.Failed($"claude auth login printed no login URL{Excerpt(stderr)}");
}
var code = (await prompt.AskForCodeAsync(url, attempt, ct).ConfigureAwait(false))?.Trim();
if (string.IsNullOrEmpty(code))
{
Kill(process);
return LoginOutcome.Failed("no code was entered");
}
try
{
await process.StandardInput.WriteLineAsync(code.AsMemory(), ct).ConfigureAwait(false);
await process.StandardInput.FlushAsync(ct).ConfigureAwait(false);
process.StandardInput.Close();
}
catch (IOException) { /* exited early; the status check decides */ }
using (var exchange = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
exchange.CancelAfter(options.ExchangeTimeout);
try { await process.WaitForExitAsync(exchange.Token).ConfigureAwait(false); }
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Kill(process);
return null; // the code hung the exchange: treat as rejected
}
}
return process.ExitCode == 0 && await IsLoggedInAsync(claudePath, env, options.StatusTimeout, ct).ConfigureAwait(false)
? LoginOutcome.Ok
: null;
}
finally
{
Kill(process);
try { await stderrPump.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); } catch (TimeoutException) { }
}
}
/// <summary>Reads stdout in chunks (the paste prompt has no newline) until the authorize URL appears.</summary>
private static async Task<string?> ReadUrlAsync(Process process, TimeSpan timeout, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
var text = new StringBuilder();
var buffer = new char[1024];
try
{
while (true)
{
var read = await process.StandardOutput.ReadAsync(buffer.AsMemory(), cts.Token).ConfigureAwait(false);
if (read == 0) return null;
text.Append(buffer, 0, read);
var match = UrlPattern().Match(text.ToString());
// The URL is complete once whitespace follows it.
if (match.Success && match.Index + match.Length < text.Length) return match.Value;
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return null;
}
}
private static async Task<bool> IsLoggedInAsync(string claudePath, IReadOnlyDictionary<string, string> env, TimeSpan timeout, CancellationToken ct)
{
var status = await ClaudeInstallation.RunAsync(claudePath, ["auth", "status"], env, timeout, ct).ConfigureAwait(false);
var json = status.Stdout.Trim();
var start = json.IndexOf('{'); var end = json.LastIndexOf('}');
if (start < 0 || end <= start) return false;
try
{
using var doc = JsonDocument.Parse(json[start..(end + 1)]);
var root = doc.RootElement;
var loggedIn = root.TryGetProperty("loggedIn", out var li) && li.ValueKind == JsonValueKind.True;
var method = root.TryGetProperty("authMethod", out var am) && am.ValueKind == JsonValueKind.String ? am.GetString() : null;
return loggedIn && method == "claude.ai";
}
catch (JsonException) { return false; }
}
private static async Task PumpAsync(StreamReader reader, StringBuilder sink)
{
var buffer = new char[1024];
int read;
try { while ((read = await reader.ReadAsync(buffer.AsMemory()).ConfigureAwait(false)) > 0) lock (sink) sink.Append(buffer, 0, read); }
catch (Exception ex) when (ex is IOException or ObjectDisposedException) { }
}
private static string Excerpt(StringBuilder stderr)
{
string text; lock (stderr) text = stderr.ToString().Trim();
if (text.Length > 300) text = text[^300..];
return text.Length == 0 ? "" : $": {text}";
}
private static void Kill(Process process)
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { }
}
[GeneratedRegex(@"https://\S*oauth/authorize\S*")]
private static partial Regex UrlPattern();
}
```
Check `ClaudeInstallation.RunAsync`'s exact signature and `ProbeResult` member names, and adapt. Cancellation must propagate as `OperationCanceledException` (the `Cancellation_throws` test). The `finally` kills the process.
- [ ] **Step 4: Run `--filter "FullyQualifiedName~ClaudeLoginTests"`; all pass.**
- [ ] **Step 5: Commit** `git commit -m "feat(claude-code): headless claude auth login driver"`
---
### Task 3: `ClaudeLoginCoordinator` — one flow at a time
**Files:**
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeLoginCoordinator.cs`
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeLoginCoordinatorTests.cs`
- [ ] **Step 1: Failing tests**
```csharp
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
public class ClaudeLoginCoordinatorTests
{
[Fact]
public async Task Concurrent_callers_share_one_flow()
{
var coordinator = new ClaudeLoginCoordinator();
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
var runs = 0;
Func<CancellationToken, Task<LoginOutcome>> flow = _ => { Interlocked.Increment(ref runs); return gate.Task; };
var first = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
var second = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
gate.SetResult(LoginOutcome.Ok);
Assert.True((await first).Success);
Assert.True((await second).Success);
Assert.Equal(1, runs);
}
[Fact]
public async Task Failure_reaches_every_waiter_and_the_next_call_starts_a_new_flow()
{
var coordinator = new ClaudeLoginCoordinator();
var runs = 0;
var a = coordinator.EnsureLoggedInAsync(_ => { runs++; return Task.FromResult(LoginOutcome.Failed("no code was entered")); }, CancellationToken.None);
Assert.Equal("no code was entered", (await a).Error);
var b = await coordinator.EnsureLoggedInAsync(_ => { runs++; return Task.FromResult(LoginOutcome.Ok); }, CancellationToken.None);
Assert.True(b.Success);
Assert.Equal(2, runs);
}
[Fact]
public async Task A_cancelled_waiter_does_not_cancel_the_flow()
{
var coordinator = new ClaudeLoginCoordinator();
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken flowToken = default;
Func<CancellationToken, Task<LoginOutcome>> flow = ct => { flowToken = ct; return gate.Task; };
using var cts = new CancellationTokenSource();
var cancelled = coordinator.EnsureLoggedInAsync(flow, cts.Token);
var other = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cancelled);
Assert.False(flowToken.IsCancellationRequested);
gate.SetResult(LoginOutcome.Ok);
Assert.True((await other).Success);
}
[Fact]
public async Task A_throwing_flow_becomes_a_failed_outcome()
{
var coordinator = new ClaudeLoginCoordinator();
var outcome = await coordinator.EnsureLoggedInAsync(_ => throw new InvalidOperationException("boom"), CancellationToken.None);
Assert.False(outcome.Success);
Assert.Contains("boom", outcome.Error);
}
}
```
- [ ] **Step 2: Run; compile failure.**
- [ ] **Step 3: Implement**
```csharp
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>
/// Keeps Claude Code logins single-flight: every session shares one <c>claude</c> configuration, so concurrent expired
/// requests wait on the same flow. The flow runs detached from any caller's token; a caller's cancellation only stops
/// its own wait. The slot clears when the flow completes, so a later expiry starts a fresh one.
/// </summary>
public sealed class ClaudeLoginCoordinator
{
public static ClaudeLoginCoordinator Shared { get; } = new();
private readonly Lock _lock = new();
private Task<LoginOutcome>? _current;
public Task<LoginOutcome> EnsureLoggedInAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(runFlow);
Task<LoginOutcome> flow;
lock (_lock)
{
flow = _current ??= StartAsync(runFlow);
}
return flow.WaitAsync(cancellationToken);
}
private async Task<LoginOutcome> StartAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow)
{
await Task.Yield(); // assign _current before the flow body runs
try
{
return await runFlow(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
return LoginOutcome.Failed(ex.Message);
}
finally
{
lock (_lock) _current = null;
}
}
}
```
If the repo's target framework lacks `System.Threading.Lock` (it's .NET 9+; the repo targets net10.0), `Lock` is fine.
- [ ] **Step 4: Run the tests; pass.**
- [ ] **Step 5: Commit** `git commit -m "feat(claude-code): single-flight login coordinator"`
---
### Task 4: Provider retries after login; `UiLoginPrompt`; options plumbing
**Files:**
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/UiLoginPrompt.cs`
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs`
- Modify: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs`
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs`, plus `UiLoginPromptTests.cs`
- [ ] **Step 1: `UiLoginPrompt` test (new file `UiLoginPromptTests.cs`).** A small recording `IUserInterface`:
```csharp
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
internal sealed class RecordingUi(params string?[] inputs) : IUserInterface
{
private int _next;
public List<(string Message, NotifyKind Kind)> Notes { get; } = [];
public List<string> Questions { get; } = [];
public Task<string?> SelectAsync(string t, IReadOnlyList<string> o, UiRequestOptions? r, CancellationToken c) => Task.FromResult<string?>(null);
public Task<bool> ConfirmAsync(string t, string m, UiRequestOptions? r, CancellationToken c) => Task.FromResult(false);
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? r, CancellationToken c)
{
Questions.Add(title);
return Task.FromResult(_next < inputs.Length ? inputs[_next++] : null);
}
public void Notify(string message, NotifyKind kind = NotifyKind.Info) => 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(["Paste the Claude login code"], ui.Questions);
}
}
```
Implement `UiLoginPrompt`:
```csharp
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";
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);
}
```
- [ ] **Step 2: Options.** In `ClaudeCodeApiOptions` add:
```csharp
/// <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; }
public ClaudeLoginOptions? LoginOptions { get; init; }
```
In `ClaudeCodeProviderOptions` add the same three properties, with the same docs. In `ClaudeCodeProviderExtension.ActivateAsync`, pass them into `apiOptions` (`Coordinator = _options.Coordinator ?? ClaudeLoginCoordinator.Shared`).
- [ ] **Step 3: Failing provider tests** in `ClaudeCodeApiTests`. Extend the `Api(...)` helper with optional `loginUi` and `coordinator` parameters (always pass `new ClaudeLoginCoordinator()` in tests so there's no shared state). The fake claude has to act differently per call. Use a state dir: a `-p` request emits `auth-expired` until `$STATE/logged-in` exists, and `tool-ok` after that. `auth login` prints the URL, reads the code and touches `logged-in`. `auth status` reports logged in when `logged-in` exists. `FakeClaude(body)` already injects the ready-marker prelude, so `body` can be:
```sh
STATE="<root>/state"; mkdir -p "$STATE"
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
echo "If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?state=x"
printf "Paste code here if prompted > "; read code; : > "$STATE/logged-in"; exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
if [ -f "$STATE/logged-in" ]; then echo '{"loggedIn":true,"authMethod":"claude.ai"}'; else echo '{"loggedIn":false}'; fi; exit 0
fi
echo run >> "$STATE/runs"
if [ -f "$STATE/logged-in" ] && [ ! -f "$STATE/expire-always" ]; then cat "<tool-ok fixture>"; else cat "<auth-expired fixture>"; fi
```
(For `auth` subcommands the prelude only looks for `--system-prompt-file`, which is harmless. Make sure `exit` in the auth branches runs before the helper's `tail`.) Tests:
```csharp
[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, _) => 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));
Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
Assert.Equal(2, File.ReadAllLines(Path.Combine(_root, "state", "runs")).Length);
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"));
}
[Fact]
public async Task Expired_again_after_login_is_reported_without_a_loop()
{
if (!Fixtures.IsPosix) return;
Directory.CreateDirectory(Path.Combine(_root, "state"));
File.WriteAllText(Path.Combine(_root, "state", "expire-always"), "");
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(new RecordingUi("code")));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]); // use the real failure event type name
Assert.Contains("[auth]", error.Message.ErrorMessage);
Assert.Equal(2, File.ReadAllLines(Path.Combine(_root, "state", "runs")).Length);
}
[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));
// Assert the error message contains "claude auth login" and "[auth]", and runs == 1.
}
[Fact]
public async Task Unanswered_login_fails_the_request()
{
if (!Fixtures.IsPosix) return;
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(new RecordingUi()));
// Assert the error contains "Claude Code login failed: no code was entered".
}
```
Look at how existing tests assert failures (the `Fail` event type and message property names in `AssistantMessageEvent`) and use those exact names. `RecordingUi` is `internal` in the same test assembly.
- [ ] **Step 4: Run; failing.**
- [ ] **Step 5: Implement the retry in `ClaudeCodeApi`.** Rename the current `RunRequestAsync` body into `RunAttemptAsync(..., bool publishFailure)` and have it **return** the exception instead of publishing it when `publishFailure == false` and the exception is a `LoginRequired` `ClaudeCodeException` and **no event has been written yet**. Track a local `var wroteAny = false;`, set to true around every `writer.WriteAsync`. Keep the `finally` (process dispose, temp cleanup, notices) as is, but move `writer.TryComplete()` out to the new outer method. Shape:
```csharp
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: true, ct).ConfigureAwait(false);
if (expired is null) return;
var builder = new AssistantMessageBuilder(model);
if (loginUi is null)
{
await writer.WriteAsync(builder.Fail(new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
"Claude Code session expired; run \"claude auth login\" on the host (or /claude-code-login from a chat UI)").FormatForUser()), CancellationToken.None).ConfigureAwait(false);
return;
}
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)
{
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)
{
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) { /* advisory */ }
}
```
`RunAttemptAsync` returns `ClaudeCodeException?`. In its `catch`: if `deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login`, return `login` without publishing. Otherwise keep the existing publish path. The `builder.IsFinished` check still applies. Make sure the "Owner could not be reached" path works when `InputAsync` returns null because sending failed: `ClaudeLogin` already maps that to "no code was entered". That's acceptable, and the spec table's "owner unreachable" wording covers the `ui is null` case.
Also check that the `StreamEventMapper` deferred-failure path (`mapper.DeferredError` → `ProtocolStream`) can't swallow the `LoginRequired` exception.
- [ ] **Step 6: Run all `ClaudeCode` tests; all pass (existing ones unchanged).**
- [ ] **Step 7: Commit** `git commit -m "feat(claude-code): pause an expired request, log in through the host UI and replay it"`
---
### Task 5: `/claude-code-login` command and doctor hint
**Files:**
- Create: `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/LoginCommand.cs`
- Modify: `ClaudeCodeProviderExtension.cs` (register), `DoctorCommand.cs` (line with `run \`claude auth login\``)
- Test: `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/LoginCommandTests.cs` and the doctor test that asserts the old wording (update it)
- [ ] **Step 1: Failing test.** Look at `DoctorCommandTests` / `ProviderExtensionTests` for how a command handler is invoked with a fake `IExtensionApi` / command context (`FakeExtensionApi` exists in `tests/.../OpenAI/FakeExtensionApi.cs`). Write:
- `Without_ui_the_command_explains_it_needs_one`: invoke the handler with a context whose `HasUi` is false. Assert that `SendMessage` got a custom message containing "needs an interactive UI".
- `With_ui_it_runs_the_login_through_that_ui`: a fake claude as in Task 2 (the login/status branches only), a `RecordingUi("good")` as the context UI. Assert `Notes` contains "login complete".
- [ ] **Step 2: Implement**
```csharp
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary><c>/claude-code-login</c>: signs the <c>claude</c> CLI in (again) through the current session's UI.</summary>
public static class LoginCommand
{
public const string Name = "claude-code-login";
public const string Description = "Sign Claude Code in to the Claude subscription (link + pasted code)";
public static CommandDefinition Create(IExtensionApi api, ClaudeCodeApi claude)
{
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(claude);
return new CommandDefinition(Description, async (_, ctx, ct) =>
{
if (!ctx.HasUi)
{
api.SendMessage(CustomMessage.FromText(Name, $"/{Name} needs an interactive UI; run \"claude auth login\" on the host instead."),
MessageDelivery.Steer, triggerTurn: false);
return;
}
var ui = ctx.Ui;
await (claude.Options.Coordinator ?? ClaudeLoginCoordinator.Shared)
.EnsureLoggedInAsync(flowCt => claude.RunLoginFlowAsync(_ => Task.FromResult<Abstractions.Ui.IUserInterface?>(ui), flowCt), ct)
.ConfigureAwait(false);
});
}
}
```
Match the real `CommandDefinition` handler signature (see `DoctorCommand.Create`). Register it in `ActivateAsync` after the doctor: `api.RegisterCommand(LoginCommand.Name, LoginCommand.Create(api, claude));`.
Doctor: change the line to ``"- Auth: **not logged in**; run `/claude-code-login` (or `claude auth login` on the host) with a Claude subscription"`` and update any test asserting the old text.
- [ ] **Step 3: Run the extension tests; all pass.**
- [ ] **Step 4: Commit** `git commit -m "feat(claude-code): /claude-code-login command"`
---
### Task 6: Telegram host — the owner receives the link
**Files:**
- Create: `hosts/Volcanic.AgenticCore.Telegram/Chats/OwnerLoginChannel.cs`
- Modify: `hosts/Volcanic.AgenticCore.Telegram/Program.cs`
- Test: `tests/Volcanic.AgenticCore.Telegram.Tests/OwnerLoginTests.cs`
- Docs: `docs/claude-code-provider.md`, `docs/telegram-bot.md`
- [ ] **Step 1: Failing test.** Using `ChatHost` (Support/ChatHost.cs) and `FakeBotClient`:
```csharp
[Fact]
public async Task Login_link_and_code_prompt_go_to_the_owner_chat_and_the_reply_answers_it()
{
await using var host = new ChatHost();
var channel = new OwnerLoginChannel(host.Chats, host.Options);
var ui = await channel.ResolveAsync(CancellationToken.None);
Assert.NotNull(ui);
var ask = new UiLoginPrompt(ui!).AskForCodeAsync("https://claude.com/cai/oauth/authorize?state=t", 1, CancellationToken.None);
// wait until the ForceReply question has been sent to Updates.AllowedUser's chat (inspect FakeBotClient's sent messages; poll briefly)
await host.SendAsync("pasted-code"); // plain text from the owner answers the pending InputAsync
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5)));
// assert FakeBotClient recorded a message to chat Updates.AllowedUser containing "oauth/authorize"
}
```
Look at `FakeBotClient` and `ChatTests` for how sent messages are inspected and how the tests wait for rendered output (for example `SettleAsync`), and use those helpers. The notification goes through `ChatRenderer.Post`, which is asynchronous, so wait for it the same way the existing notification tests do.
- [ ] **Step 2: Implement**
```csharp
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The Claude Code login channel: the private chat of the first allowed user (the bot owner). In a private chat the chat
/// id is the user id, so the chat is created on demand even if the owner has not written yet (sending then fails
/// unless the owner has started the bot, which surfaces as an unanswered login).
/// </summary>
public sealed class OwnerLoginChannel(ChatRegistry chats, TelegramOptions options)
{
public async Task<IUserInterface?> ResolveAsync(CancellationToken cancellationToken)
{
if (options.AllowedUserIds.Count == 0) return null;
return (await chats.GetAsync(options.AllowedUserIds[0], cancellationToken).ConfigureAwait(false)).Ui;
}
}
```
`Program.cs`:
```csharp
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this; LoginUi resolves lazily to
// avoid a construction cycle (ChatRegistry -> session factory -> extensions).
builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
{
LoginUi = ct => sp.GetRequiredService<OwnerLoginChannel>().ResolveAsync(ct),
});
```
Check that `ActivatorUtilities.CreateInstance` picks the constructor with the optional parameter when the service is registered. It does when the type is resolvable, but confirm with a quick test or by reading `ExtensionRegistration`. If it doesn't, register the extension with `o.Extensions.Add(ExtensionRegistration.FromFactory(...))` using the options instance.
- [ ] **Step 3: Docs.** In `docs/claude-code-provider.md`, add a "Session expiry and re-login" section: detection, pause-and-replay, single flight, `LoginUi`, the headless message, `/claude-code-login`, and 3 attempts / 10-minute prompt. In `docs/telegram-bot.md`, say that the first allowed user gets login links and must have started the bot.
- [ ] **Step 4: Run the full suite** `dotnet test VolcanicAgenticCore.slnx`; everything passes. `dotnet build` shows no new warnings (the repo may use TreatWarningsAsErrors via Directory.Build.props).
- [ ] **Step 5: Commit** `git commit -m "feat(telegram): send Claude Code login links to the bot owner"`

View File

@@ -0,0 +1,201 @@
# Claude Code re-login through the host UI — design
Date: 2026-09-25
Status: approved
## Problem
The `claude-code` provider drives the local `claude` CLI on a claude.ai subscription. When the CLI's OAuth
session expires (the refresh token is gone), every request fails with a 401 / "OAuth token has expired" /
"Please run /login" result. Today that surfaces as a generic `[protocol_stream]` error and the only fix is to
shell into the host and run `claude auth login`. The bot owner should instead get the login link in the host UI
(Telegram today), paste the confirmation code back, and have the paused request continue.
## Verified CLI behaviour
`claude auth login --claudeai` (2.1.280) works over plain pipes, no TTY:
```
Opening browser to sign in…
If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&client_id=…&state=…
Paste code here if prompted >
```
It then reads the code from stdin, exchanges it and exits (0 on success). Each run has its own PKCE verifier and
state, so a code only works with the process that printed its link. `claude auth status` (JSON) reports
`loggedIn` and `authMethod` afterwards.
## Decisions
- **Pause and replay (option A).** An expired session found before any output streamed pauses the request, runs
the login and replays the request once. The user never resends.
- **Only the owner gets the link.** The owner is the first entry of `Telegram:AllowedUserIds`. The link and code
prompt always go to the owner's private chat, whichever chat's request failed. Other chats only get a notice.
- **One login at a time.** All sessions share one `claude` configuration, so there's never more than one login
flow. Concurrent requests wait on the same flow.
- **No proactive checks.** Claude refreshes tokens itself, so a 401 means the refresh token is really gone.
There's no `auth status` call before each request.
## Components
### 1. Detection — `StreamEventMapper` and `ClaudeCodeException`
- `ClaudeCodeException` gains `bool LoginRequired` (constructor parameter, default false).
- In `AcceptResult`, an error result is an expired session when `api_error_status` is 401, or when the detail text
matches (case-insensitive) any of: `OAuth token has expired`, `Please run /login`, `Invalid API key`,
`authentication_failed`, `Not logged in`. It throws
`ClaudeCodeException(Codes.Auth, "Claude Code session expired: <detail>", loginRequired: true)`.
- A pre-stream `system/api_retry` record with `error == "authentication_failed"` is noted. A following error
result, or an exit with no terminal record, is then reported as the same login-required failure.
- The existing `auth` failures (a denied environment variable, `apiKeySource != "none"`) keep
`LoginRequired = false`: logging in doesn't fix them.
- New fixtures, using the fixture naming convention: `claude-2.1.281-stream-auth-expired.jsonl` (a 401 error
result) and `claude-2.1.281-stream-auth-login.jsonl` (an error result whose text is "Invalid API key · Please
run /login").
### 2. The login flow — `ClaudeLogin`
`ClaudeLogin.RunAsync(string claudePath, IReadOnlyDictionary<string,string> env, ILoginPrompt prompt, ClaudeLoginOptions, CancellationToken) → Task<LoginOutcome>`
- `ILoginPrompt` separates the flow from the UI:
`Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken)`, which returns null when there's no
answer or it was cancelled. It also has `void Report(string message)`.
- For each attempt (max 3):
1. Start `claude auth login --claudeai` as a plain `System.Diagnostics.Process` (not `ClaudeProcess`, which is
built around the JSONL stream and its idle/total timeouts), with the allow-listed environment plus
`BROWSER=/bin/false` on Unix (`BROWSER=none` on Windows). Stdout is read as text in chunks, since the paste
prompt has no trailing newline.
2. Read stdout until a line contains `https://` followed by `oauth/authorize`. Take the URL up to the next
whitespace. Allow 30 s (`ClaudeLoginOptions.UrlTimeout`). If there's no URL, or the process exits early,
the result is `Failed("claude auth login printed no login URL: <stderr excerpt>")`.
3. `prompt.AskForCodeAsync(url, attempt)`, bounded by `ClaudeLoginOptions.CodeTimeout` (10 min) whatever the
prompt does. A null result (or the timeout) kills the process and returns `Failed("no code was entered")`.
4. Trim the code and write it plus `\n` to stdin, then wait up to 60 s (`ClaudeLoginOptions.ExchangeTimeout`)
for exit.
5. Run `claude auth status` (through `ClaudeInstallation.RunAsync`). The attempt succeeds only when the exit
code was 0, `loggedIn` is true and `authMethod` is `claude.ai`.
6. Otherwise, `prompt.Report("That code was not accepted; here is a new link.")` and start the next attempt.
- After 3 failed attempts: `Failed("the code was rejected 3 times")`.
- The process is always killed (process tree) on cancel, timeout or exception. The code and URL never appear in
exception messages or logs.
- `LoginOutcome` is `record LoginOutcome(bool Success, string? Error)`.
- `ClaudeLoginOptions` holds the budget: `MaxAttempts` (3), `UrlTimeout` (30 s), `ExchangeTimeout` (60 s),
`StatusTimeout` (10 s), `CodeTimeout` (10 min, per code prompt) and `OwnerTimeout` (30 s, for resolving
`LoginUi`).
### 3. Single flight — `ClaudeLoginCoordinator`
- `ClaudeLoginCoordinator.Shared` is process-wide, like `SessionRegistry.Shared`, and overridable through
options for tests.
- `Task<LoginOutcome> EnsureLoggedInAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow, CancellationToken ct)`:
- If no flow is running, it starts `runFlow` with the coordinator's own token (not the caller's), so one
waiter's `/stop` doesn't kill the flow for everyone else. It stores the task.
- Every caller awaits the stored task with `WaitAsync(ct)`. A cancelled caller only stops waiting.
- When the task completes, the slot clears. The next expired-session request starts a new flow.
- `long Generation` counts successful flows; it is advanced before the slot clears. A request reads it before
its attempt starts (see §5) so a late expiry after someone else's successful login is recognised as stale.
- The flow is abandoned when the owner doesn't answer: `ClaudeLoginOptions.CodeTimeout` (10 min) bounds each code
prompt whatever the UI does, so the flow fails and every waiter fails. Resolving `LoginUi` is bounded to 30 s.
### 4. The login channel — `ClaudeCodeProviderOptions.LoginUi`
- New option: `Func<CancellationToken, Task<IUserInterface?>>? LoginUi`. It returns where the owner is reached,
or null if nobody can be reached right now.
- An adapter, `UiLoginPrompt(IUserInterface ui)`, implements `ILoginPrompt`:
- `AskForCodeAsync`: a single `ui.InputAsync("Claude Code needs you to sign in again.\n1. Open: <url>\n2. Sign in with the Claude subscription account.\n3. Paste the Claude login code shown on the page as a reply.", "code", null, ct)`.
The link is part of the question's title rather than a separate `Notify`: on Telegram notifications go through
the renderer's async queue while the question is sent directly, so separate messages could arrive out of order.
- `Report`: `ui.Notify(message, NotifyKind.Warning)`.
- After success the owner gets `Notify("Claude Code login complete ✓")`. After failure:
`Notify("Claude Code login failed: <reason>", Error)`.
- **Headless hosts** (`LoginUi` is null): no flow runs. The request fails with
`[auth] Claude Code session expired; run "claude auth login" on the host (or /claude-code-login from a chat UI)`.
- **Telegram host:** `Program.cs` registers a `ClaudeCodeProviderOptions` singleton. `ActivatorUtilities` hands it
to the extension's constructor. Its `LoginUi` resolves lazily through the service provider:
`ct => sp.GetRequiredService<OwnerLoginChannel>().ResolveAsync(ct)`. `OwnerLoginChannel` returns the UI of the
private chat of `AllowedUserIds[0]` (created on demand through `ChatRegistry`; in a private chat the chat id is
the user id), or null when the allow-list is empty. It resolves at call time to avoid a construction cycle.
While the code prompt is pending, the owner's next plain text message (not a command, not a photo) answers it
instead of reaching the agent.
- **Owner unreachable:** only when `LoginUi` returns null (e.g. an empty allow-list) or doesn't resolve within
`OwnerTimeout` (30 s) does the flow fail with
`the owner could not be reached; the first allowed user must start a chat with the bot`. An owner who never
started the bot still resolves (the chat is created on demand), but sending the prompt fails, `InputAsync`
returns null and the flow fails with `no code was entered`.
### 5. Provider integration — `ClaudeCodeApi`
- `RunRequestAsync` gets split: `RunAttemptAsync` does one process run and reports its outcome, which is either
a failure exception or success, plus whether any event reached the channel. The outer method decides on the
retry.
- Before attempt 1 the request reads the coordinator's `Generation`.
- If attempt 1 fails with `LoginRequired`, **no event has been written**, the request wasn't cancelled and a
login UI is available:
0. If `Generation` has advanced since (a login completed while this attempt ran), the expiry is stale: run
attempt 2 directly, with no notice and no new flow. Still exactly one replay.
1. Notify the requesting session (through its registry `Notify`):
`Claude Code session expired; waiting for the bot owner to sign in…`
2. `await coordinator.EnsureLoggedInAsync(flow, ct)`, where `flow` resolves `LoginUi`, then runs `ClaudeLogin`
and the owner notifications.
3. Success: run attempt 2 with the same inputs. A failure there, including another `LoginRequired`, is
published as-is. There's no loop.
4. Failure: publish `[auth] Claude Code login failed: <reason>`.
- Cancelling while waiting publishes the usual `aborted` failure.
- Waiting for login counts against neither the idle nor the total timeout. Those are per-process, and attempt 2
gets fresh ones.
- `ClaudeCodeApiOptions` gains `LoginUi`, `Coordinator` and `LoginOptions` (`ClaudeLoginOptions`: `CodeTimeout`,
`OwnerTimeout` and the rest of §2's budget). `ClaudeCodeProviderExtension` fills them in from the same-named
`ClaudeCodeProviderOptions`.
### 6. `/claude-code-login` command
- `LoginCommand.Name = "claude-code-login"`. It runs the same coordinator flow through the session's own UI
(`ctx.Ui`), with no owner redirect: whoever runs the command is already allow-listed and at the keyboard. Without
a UI (`!ctx.HasUi`), it appends a message saying the command needs an interactive UI.
- The result is reported with `Notify`. It also runs when the session is still valid, to switch accounts or
refresh early.
- If a flow is already running (driven through another UI, e.g. the owner chat for another chat's expired
request), the command joins it and reports its outcome in its own UI:
`A Claude Code login was already in progress elsewhere; it succeeded ✓` or `…; it failed: <reason>`.
- The doctor's "not logged in" line becomes: `run /claude-code-login (or claude auth login on the host)`.
## Error handling summary
| Situation | Result |
|---|---|
| 401 / expired, login UI available, owner pastes a valid code | request replays and succeeds |
| owner pastes wrong code | new link, up to 3 attempts |
| owner doesn't answer in 10 min (`CodeTimeout`) | every waiting request fails `[auth] Claude Code login failed: no code was entered` |
| requesting user sends `/stop` while waiting | that request aborts; the flow continues for the others |
| no login UI (headless) | `[auth] Claude Code session expired; run "claude auth login"…` |
| `LoginUi` returns null / doesn't resolve in 30 s (e.g. empty allow-list) | `[auth] Claude Code login failed: the owner could not be reached…` |
| owner never started the bot | `[auth] Claude Code login failed: no code was entered` |
| expiry reported after another request's login already succeeded | replays directly, no second login |
| expired again right after login | attempt 2's error is published, no loop |
| `apiKeySource != none` / denied env var | unchanged `[auth]` failure, no login |
## Testing
- **Mapper:** both new fixtures give `ClaudeCodeException { Code: auth, LoginRequired: true }`. A normal
error result keeps `LoginRequired == false`. `apiKeySource` failures keep `LoginRequired == false`.
- **`ClaudeLogin`:** a fake `claude` shell script (as in `ClaudeInstallationTests`) that prints the URL block, reads
a line and then writes a marker file that its `auth status` branch reads. It covers success, a wrong code then
the right one (the prompt sees attempt 2 with a new URL), three wrong codes, no URL, a null answer (process
killed) and cancellation. These are Unix-only, like the existing script-based tests.
- **Coordinator:** two concurrent callers share one flow invocation. Both see success or both see failure. A
cancelled caller doesn't cancel the flow. A new flow starts after completion. `Generation` advances only on
success; a request whose attempt spanned a completed login replays without a second flow.
- **Provider:** a fake `claude` whose first `-p` run emits the 401 fixture and whose second emits the tool-ok
fixture. With a fake `LoginUi`, the request ends `Done`, the flow ran once and the session was notified. Two
more cases: expired again after login is an error with no third run, and no `LoginUi` gives the headless
`[auth]` message.
- **Telegram:** with the fake bot client and the real extension (per session, as in `Program.cs`) over a fake
`claude` script, a request in a non-owner chat hits an expired session. The link and the prompt appear in the
owner's chat as one message and the non-owner chat gets the waiting notice. A reply in the owner's chat resumes
the request. A photo never answers a pending prompt.
## Out of scope
- Proactive expiry checks, and token refresh on our side.
- Choosing a login target other than the first allowed user.
- Hosts other than Telegram. Any host can provide `LoginUi`.

View File

@@ -28,7 +28,7 @@ Environment variables, or the `Telegram` section of `appsettings.json` (`Telegra
|---|---|---|
| `TELEGRAM_BOT_TOKEN` / `Telegram:BotToken` | — | Required |
| `TELEGRAM_ALLOWED_USER_IDS` / `Telegram:AllowedUserIds` | — | Required, comma separated. The bot refuses to start without it |
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>` |
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` there |
| `Telegram:Model` | `claude-code/claude-opus-5-5` | Any `provider/model` the Claude Code provider serves |
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
| `Telegram:ReconnectInitialDelay` / `ReconnectMaxDelay` | `1 s` / `60 s` | Backoff after lost connection |
@@ -62,9 +62,20 @@ A message sent while the agent works steers the current run. Photos (with or wit
| `/thinking [level]` | Show or set the thinking level (Claude Code `--effort`) |
| `/help` | Short help |
Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` work.
Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` and
`/claude-code-login` work.
When an extension asks something, the question appears with buttons (or as a reply prompt for free text).
If the Claude subscription session behind the bot expires, the **first id in `AllowedUserIds`** (the bot owner)
gets the re-login link and code prompt in their own private chat, regardless of which chat's request triggered
it. That user must already have started a chat with the bot — Telegram bots cannot message a user first — or
the login goes unanswered and the triggering request fails ("no code was entered"). The link, the steps and the
code prompt arrive as one message. While that prompt is pending, the owner's next plain text message in that chat
is taken as the code and is not sent to the agent; photos and `/commands` still behave normally. Don't ask the bot
something else in the owner chat until the code is in: a second free-text question in that chat (for example from
an extension) replaces the pending prompt and cancels the login. See
[claude-code-provider.md](claude-code-provider.md#session-expiry-and-re-login).
## Security
The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids

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,89 @@ 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 coordinator = Options.Coordinator ?? ClaudeLoginCoordinator.Shared;
var generation = coordinator.Generation;
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.
// A login that succeeded after this attempt started makes the expiry stale: replay without another flow.
if (coordinator.Generation != generation)
{
await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: false, ct).ConfigureAwait(false);
return;
}
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 coordinator.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);
const string unreachable = "the owner could not be reached; the first allowed user must start a chat with the bot";
IUserInterface? ui;
using (var resolve = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
resolve.CancelAfter((Options.LoginOptions ?? new ClaudeLoginOptions()).OwnerTimeout);
try { ui = await loginUi(resolve.Token).WaitAsync(resolve.Token).ConfigureAwait(false); }
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { ui = null; }
}
if (ui is null) return LoginOutcome.Failed(unreachable);
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);
var claudePath = Options.ClaudePath;
if (string.IsNullOrEmpty(claudePath) || !File.Exists(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(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 +175,37 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
Action<string, NotifyKind>? notify = null;
var terminatedForHandoff = false;
var responseAnnounced = false;
var wroteAny = false;
var responsePending = false;
AssistantMessageEvent? heldStart = null;
// After-response observers run once per published message, ahead of its Start.
async ValueTask AnnounceResponseAsync()
{
if (!responsePending) return;
responsePending = false;
if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false);
}
// A deferring attempt holds the init Start (and the response announcement) back until something else is published,
// so a replay never publishes two Starts and a dropped attempt is never announced.
async ValueTask PublishAsync(AssistantMessageEvent e)
{
if (deferLoginFailure && !wroteAny && heldStart is null && e is AssistantMessageEvent.Start)
{
heldStart = e;
return;
}
await AnnounceResponseAsync().ConfigureAwait(false);
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();
@@ -171,9 +295,10 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
// A validated initialization is this transport's analogue of a received response; announce it before
// any content so after-response observers run ahead of the Start event.
responseAnnounced = true;
if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false);
responsePending = true;
if (!deferLoginFailure) await AnnounceResponseAsync().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.
@@ -196,14 +321,23 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
if (mapper.HandoffLatched)
{
// The provider terminated Claude on purpose: 143 (SIGTERM), 137 (SIGKILL), or whatever a late exit reports.
await AnnounceResponseAsync().ConfigureAwait(false);
done = mapper.Complete();
}
else if (mapper.HasSuccessfulResult)
{
if (exitCode != 0)
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProcessExit, $"Claude Code exited after a successful result (code {exitCode}){StderrDetail(process, tempDir)}");
// Announced before Complete finishes the builder, so a throwing observer still gets a failure published.
await AnnounceResponseAsync().ConfigureAwait(false);
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 +346,24 @@ 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;
if (responsePending)
{
// The request is already failing; an observer error must not keep the failure from being published.
try { await AnnounceResponseAsync().ConfigureAwait(false); } catch (Exception) { /* already failing */ }
}
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 +376,6 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
try { notify(notice, NotifyKind.Warning); } catch (Exception) { /* notifications are advisory */ }
}
}
writer.TryComplete();
}
}
@@ -289,8 +430,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

@@ -1,13 +1,16 @@
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>A typed failure of the Claude Code transport. <see cref="Code"/> is stable and machine-readable.</summary>
public sealed class ClaudeCodeException(string code, string message, bool retryable = false) : Exception(message)
public sealed class ClaudeCodeException(string code, string message, bool retryable = false, bool loginRequired = false) : Exception(message)
{
public string Code { get; } = code;
/// <summary>True when repeating the request from the unchanged context can succeed (a mid-stream interruption).</summary>
public bool Retryable { get; } = retryable;
/// <summary>True when the CLI's subscription session expired and <c>claude auth login</c> would clear the failure.</summary>
public bool LoginRequired { get; } = loginRequired;
/// <summary>
/// The message the provider publishes: <c>[code] message</c>, except that a context overflow keeps its
/// <c>context_length_exceeded:</c> prefix intact because the core's retry policy keys on it.

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,
LoginOptions = _options.LoginOptions,
};
var claude = new ClaudeCodeApi(registry, apiOptions);
Api = claude;
@@ -63,6 +75,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
Stream = claude.StreamAsync,
});
api.RegisterCommand(DoctorCommand.Name, DoctorCommand.Create(api, apiOptions, registry));
api.RegisterCommand(LoginCommand.Name, LoginCommand.Create(api, claude));
api.On<SessionStartEvent>((_, ctx, _) =>
{

View File

@@ -0,0 +1,200 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>Where the login link goes and where the pasted code comes from.</summary>
public interface ILoginPrompt
{
/// <summary>Show the link and wait for the code. Null when there is no answer (timeout, cancelled dialog, unreachable user).</summary>
Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken cancellationToken);
/// <summary>A progress or retry message for the person logging in.</summary>
void Report(string message);
}
/// <summary>The result of a <see cref="ClaudeLogin"/> run.</summary>
public sealed record LoginOutcome(bool Success, string? Error)
{
public static readonly LoginOutcome Ok = new(true, null);
public static LoginOutcome Failed(string error) => new(false, error);
}
/// <summary>Timeouts and retry budget for <see cref="ClaudeLogin.RunAsync"/>.</summary>
public sealed record ClaudeLoginOptions
{
public int MaxAttempts { get; init; } = 3;
public TimeSpan UrlTimeout { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan ExchangeTimeout { get; init; } = TimeSpan.FromSeconds(60);
public TimeSpan StatusTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>How long the person has to paste a code; an unanswered prompt then counts as no code.</summary>
public TimeSpan CodeTimeout { get; init; } = TimeSpan.FromMinutes(10);
/// <summary>How long resolving where the owner is reached may take before the owner counts as unreachable.</summary>
public TimeSpan OwnerTimeout { get; init; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Runs <c>claude auth login --claudeai</c> headlessly: it prints the authorize URL and waits for the code on stdin.
/// Each attempt is a fresh process (its own PKCE state), so a rejected code gets a new link. Success is confirmed by
/// <c>claude auth status</c>. The URL and code never appear in errors.
/// </summary>
public static partial class ClaudeLogin
{
public static async Task<LoginOutcome> RunAsync(string claudePath, IReadOnlyDictionary<string, string> environment, ILoginPrompt prompt,
ClaudeLoginOptions? options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(prompt);
options ??= new ClaudeLoginOptions();
var env = new Dictionary<string, string>(environment, StringComparer.Ordinal) { ["BROWSER"] = OperatingSystem.IsWindows() ? "none" : "/bin/false" };
for (var attempt = 1; attempt <= options.MaxAttempts; attempt++)
{
var result = await AttemptAsync(claudePath, env, prompt, attempt, options, cancellationToken).ConfigureAwait(false);
if (result is not null) return result;
if (attempt < options.MaxAttempts) prompt.Report("That code was not accepted; here is a new link.");
}
return LoginOutcome.Failed($"the code was rejected {options.MaxAttempts} times");
}
/// <summary>A final outcome, or null when the code was rejected and another attempt may follow.</summary>
private static async Task<LoginOutcome?> AttemptAsync(string claudePath, Dictionary<string, string> env, ILoginPrompt prompt, int attempt,
ClaudeLoginOptions options, CancellationToken ct)
{
var psi = new ProcessStartInfo(claudePath)
{
UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true,
};
psi.ArgumentList.Add("auth"); psi.ArgumentList.Add("login"); psi.ArgumentList.Add("--claudeai");
psi.Environment.Clear();
foreach (var (name, value) in env) psi.Environment[name] = value;
using var process = new Process { StartInfo = psi };
try { process.Start(); }
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException)
{
return LoginOutcome.Failed($"claude auth login could not be started: {ex.Message}");
}
var stderr = new StringBuilder();
var stderrPump = PumpAsync(process.StandardError, stderr);
try
{
var url = await ReadUrlAsync(process, options.UrlTimeout, ct).ConfigureAwait(false);
if (url is null)
{
Kill(process);
return LoginOutcome.Failed($"claude auth login printed no login URL{Excerpt(stderr)}");
}
string? code;
using (var answer = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
answer.CancelAfter(options.CodeTimeout);
// WaitAsync bounds a prompt that ignores its token: the coordinator slot and this child must not outlive it.
try { code = (await prompt.AskForCodeAsync(url, attempt, answer.Token).WaitAsync(answer.Token).ConfigureAwait(false))?.Trim(); }
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { code = null; }
}
if (string.IsNullOrEmpty(code))
{
Kill(process);
return LoginOutcome.Failed("no code was entered");
}
try
{
await process.StandardInput.WriteLineAsync(code.AsMemory(), ct).ConfigureAwait(false);
await process.StandardInput.FlushAsync(ct).ConfigureAwait(false);
process.StandardInput.Close();
}
catch (IOException) { /* exited early; the status check decides */ }
using (var exchange = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
exchange.CancelAfter(options.ExchangeTimeout);
try { await process.WaitForExitAsync(exchange.Token).ConfigureAwait(false); }
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Kill(process);
return null; // the code hung the exchange: treat as rejected
}
}
return process.ExitCode == 0 && await IsLoggedInAsync(claudePath, env, options.StatusTimeout, ct).ConfigureAwait(false)
? LoginOutcome.Ok
: null;
}
finally
{
Kill(process);
try { await stderrPump.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); } catch (TimeoutException) { }
}
}
/// <summary>Reads stdout in chunks (the paste prompt has no newline) until the authorize URL appears.</summary>
private static async Task<string?> ReadUrlAsync(Process process, TimeSpan timeout, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
var text = new StringBuilder();
var buffer = new char[1024];
try
{
while (true)
{
var read = await process.StandardOutput.ReadAsync(buffer.AsMemory(), cts.Token).ConfigureAwait(false);
if (read == 0) return null;
text.Append(buffer, 0, read);
var match = UrlPattern().Match(text.ToString());
// The URL is complete once whitespace (or anything else) follows it.
if (match.Success && match.Index + match.Length < text.Length) return match.Value;
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return null;
}
}
private static async Task<bool> IsLoggedInAsync(string claudePath, IReadOnlyDictionary<string, string> env, TimeSpan timeout, CancellationToken ct)
{
var status = await ClaudeInstallation.RunAsync(claudePath, ["auth", "status"], env, timeout, ct).ConfigureAwait(false);
var json = status.Stdout.Trim();
var start = json.IndexOf('{'); var end = json.LastIndexOf('}');
if (start < 0 || end <= start) return false;
try
{
using var doc = JsonDocument.Parse(json[start..(end + 1)]);
var root = doc.RootElement;
var loggedIn = root.TryGetProperty("loggedIn", out var li) && li.ValueKind == JsonValueKind.True;
var method = root.TryGetProperty("authMethod", out var am) && am.ValueKind == JsonValueKind.String ? am.GetString() : null;
return loggedIn && method == "claude.ai";
}
catch (JsonException) { return false; }
}
private static async Task PumpAsync(StreamReader reader, StringBuilder sink)
{
var buffer = new char[1024];
int read;
try { while ((read = await reader.ReadAsync(buffer.AsMemory()).ConfigureAwait(false)) > 0) lock (sink) sink.Append(buffer, 0, read); }
catch (Exception ex) when (ex is IOException or ObjectDisposedException) { }
}
private static string Excerpt(StringBuilder stderr)
{
string text; lock (stderr) text = stderr.ToString().Trim();
if (text.Length > 300) text = text[^300..];
return text.Length == 0 ? "" : $": {text}";
}
private static void Kill(Process process)
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { }
}
[GeneratedRegex(@"https://\S*oauth/authorize\S*")]
private static partial Regex UrlPattern();
}

View File

@@ -0,0 +1,59 @@
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>
/// Keeps Claude Code logins single-flight: every session shares one <c>claude</c> configuration, so concurrent expired
/// requests wait on the same flow. The flow runs detached from any caller's token; a caller's cancellation only stops
/// its own wait. The slot clears when the flow completes, so a later expiry starts a fresh one. Each successful flow
/// advances <see cref="Generation"/>, so a request whose attempt started before a login finished can tell that its
/// late expiry is stale and replay without starting another flow.
/// </summary>
public sealed class ClaudeLoginCoordinator
{
/// <summary>The coordinator shared by every extension instance in the process.</summary>
public static ClaudeLoginCoordinator Shared { get; } = new();
private readonly Lock _lock = new();
private Task<LoginOutcome>? _current;
private long _generation;
/// <summary>How many flows have succeeded so far; read it before an attempt, compare after a login-required failure.</summary>
public long Generation => Interlocked.Read(ref _generation);
/// <summary>Joins the in-flight login, or starts one. <paramref name="runFlow"/> only ever runs for the caller that starts it.</summary>
public Task<LoginOutcome> EnsureLoggedInAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(runFlow);
Task<LoginOutcome> flow;
lock (_lock)
{
flow = _current ??= StartAsync(runFlow);
}
return flow.WaitAsync(cancellationToken);
}
private async Task<LoginOutcome> StartAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow)
{
// Unconditional suspension before touching _current again: without it, a runFlow that completes (or throws)
// synchronously would run this whole method - including the finally below - inside the very statement in
// EnsureLoggedInAsync that assigns _current, so that assignment would run *after* the finally's clear and
// overwrite it with the now-finished task. The slot would then stay stuck on a stale result forever, since
// nothing else ever clears it. Task.Yield() guarantees the finally always runs on a later, separate turn.
await Task.Yield();
LoginOutcome outcome;
try
{
outcome = await runFlow(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
outcome = LoginOutcome.Failed(ex.Message);
}
lock (_lock)
{
// Advanced before the slot clears: a caller that finds the slot empty already sees the new generation.
if (outcome.Success) Interlocked.Increment(ref _generation);
_current = null;
}
return outcome;
}
}

View File

@@ -81,7 +81,7 @@ public static class DoctorCommand
else
{
ok = false;
lines.Add("- Auth: **not logged in**; run `claude auth login` with a Claude subscription");
lines.Add("- Auth: **not logged in**; run `/claude-code-login` (or `claude auth login` on the host) with a Claude subscription");
}
if (info.MissingFlags.Count == 0) lines.Add("- Required flags: all present");
else

View File

@@ -0,0 +1,46 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>
/// <c>/claude-code-login</c>: signs the <c>claude</c> CLI in (again) through the current session's own UI, with no
/// owner redirect - whoever runs the command is already allow-listed and at the keyboard. Also useful to switch
/// accounts or refresh a session that hasn't expired yet. The outcome is reported by <see cref="ClaudeCodeApi.LoginThroughAsync"/>
/// through that same UI; when the command joins a login already running elsewhere (whose UI is another chat), it
/// reports that login's outcome here itself.
/// </summary>
public static class LoginCommand
{
public const string Name = "claude-code-login";
public const string Description = "Sign Claude Code in to the Claude subscription (link + pasted code)";
public static CommandDefinition Create(IExtensionApi api, ClaudeCodeApi claude)
{
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(claude);
return new CommandDefinition(Description, async (_, ctx, ct) =>
{
if (!ctx.HasUi)
{
api.SendMessage(
CustomMessage.FromText(Name, $"/{Name} needs an interactive UI; run \"claude auth login\" on the host instead."),
MessageDelivery.Steer, triggerTurn: false);
return;
}
var ui = ctx.Ui;
var drivenHere = false;
var outcome = await (claude.Options.Coordinator ?? ClaudeLoginCoordinator.Shared)
.EnsureLoggedInAsync(flowCt =>
{
drivenHere = true;
return claude.RunLoginFlowAsync(_ => Task.FromResult<IUserInterface?>(ui), flowCt);
}, ct)
.ConfigureAwait(false);
if (drivenHere) return;
if (outcome.Success) ui.Notify("A Claude Code login was already in progress elsewhere; it succeeded ✓");
else ui.Notify($"A Claude Code login was already in progress elsewhere; it failed: {outcome.Error}", NotifyKind.Error);
});
}
}

View File

@@ -97,8 +97,8 @@ public sealed partial class SessionRegistry
/// <summary>
/// The working directory a system prompt states: the last <c>&lt;cwd&gt;</c> section or the last
/// <c>Working directory: X</c> line, whichever comes later. Sections nested in project context (repository-authored
/// instruction files) are discarded so a repository can never choose where Claude runs.
/// <c>Working directory: X</c> line, whichever comes later. Sections nested in project context, persona or memory
/// (workspace-authored files) are discarded so a repository can never choose where Claude runs.
/// </summary>
public static string? PromptWorkingDirectory(string? systemPrompt)
{
@@ -144,7 +144,7 @@ public sealed partial class SessionRegistry
lock (_lock) return _detached ??= new ClaudeImageStore();
}
[GeneratedRegex(@"<(project_context|project_instructions)\b[^>]*>[\s\S]*?</\1>", RegexOptions.CultureInvariant)]
[GeneratedRegex(@"<(project_context|project_instructions|persona|memory)\b[^>]*>[\s\S]*?</\1>", RegexOptions.CultureInvariant)]
private static partial Regex ProjectContextSpan();
[GeneratedRegex(@"<cwd>\r?\n([^\n]+)\r?\n</cwd>", RegexOptions.CultureInvariant)]

View File

@@ -18,6 +18,9 @@ public sealed partial class StreamEventMapper
// api_retry categories that describe a transient transport or server failure. Every other category
// (billing_error, authentication_failed, invalid_request, ...) cannot be cleared by repeating the turn.
private static readonly HashSet<string> RetryableInterruptions = new(StringComparer.Ordinal) { "overloaded", "server_error", "unknown" };
// Wordings Claude Code (or the API behind it) uses for an expired subscription session; matched case-insensitively.
private static readonly string[] LoginPhrases =
["OAuth token has expired", "Please run /login", "Invalid API key", "authentication_failed", "Not logged in"];
private const string InterruptedPhrase = "Claude Code API stream ended before message_stop";
private const long EpochMillisecondsThreshold = 100_000_000_000;
@@ -30,6 +33,7 @@ public sealed partial class StreamEventMapper
private bool _messageStopped;
private bool _successfulResult;
private bool _finished;
private bool _authenticationFailed;
private string? _streamMessageId;
private string? _stopReason;
private string? _deferredToolArguments;
@@ -74,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;
@@ -146,6 +153,10 @@ public sealed partial class StreamEventMapper
return Builder.Done(PendingStop ?? StopReason.Stop);
}
/// <summary>Whether an error result's detail (or status) says the subscription session is gone.</summary>
public static bool IsLoginRequired(int? status, string? detail) =>
status == 401 || (detail is not null && LoginPhrases.Any(p => detail.Contains(p, StringComparison.OrdinalIgnoreCase)));
/// <summary>Whether an error message is the API's context overflow, in any of Claude Code's wordings.</summary>
public static bool IsOverflow(string? message) => message is not null && Overflow().IsMatch(message);
@@ -447,6 +458,9 @@ public sealed partial class StreamEventMapper
? $" ({statusElement.GetRawText()})"
: "";
var detail = FirstNonEmpty(resultText, _assistantDiagnostic, ErrorsText(record), Str(record, "terminal_reason"), _rateLimitFailure) ?? "unknown error";
int? statusCode = statusElement.ValueKind == JsonValueKind.Number && statusElement.TryGetInt32(out var code) ? code : null;
if (_authenticationFailed || IsLoginRequired(statusCode, detail))
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth, $"Claude Code session expired: {Truncate(detail, 512)}", loginRequired: true);
var message = $"Claude Code request failed{status}: {detail}";
if (IsOverflow(message))
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContextOverflow, $"context_length_exceeded: {message}");
@@ -501,6 +515,10 @@ public sealed partial class StreamEventMapper
/// </summary>
private void RejectInterruptedStream(JsonElement record, string? type, string? subtype)
{
// A pre-stream retry the CLI attributes to an expired session: noted here (ahead of the early return below) so
// a following error result, or an exit with no terminal record, is reported as the same login-required failure.
if (type == "system" && subtype == "api_retry" && Str(record, "error") == "authentication_failed")
_authenticationFailed = true;
// Before the response starts a retry is an ordinary pre-stream retry. After a tool-use or length stop the
// provider is already terminating Claude; records about Claude's own next request must not invalidate the proposal.
if (!_messageStarted || _stopReason == "tool_use" || IsLengthStop(_stopReason)) return;
@@ -538,6 +556,7 @@ public sealed partial class StreamEventMapper
private void AcceptAssistant(JsonElement record)
{
if (Str(record, "error") == "authentication_failed") _authenticationFailed = true;
if (_assistantDiagnostic is not null) return;
var error = Str(record, "error")?.Trim() ?? "";
var text = "";

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 instructions and the link are the title of the free-text
/// question for the code, one message, so the link can never arrive after (or without) the question.
/// </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) =>
await _ui.InputAsync("Claude Code needs you to sign in again.\n" +
$"1. Open: {url}\n" +
"2. Sign in with the Claude subscription account.\n" +
$"3. {CodeQuestion} shown on the page as a reply.", "code", null, cancellationToken).ConfigureAwait(false);
public void Report(string message) => _ui.Notify(message, NotifyKind.Warning);
}

View File

@@ -0,0 +1,17 @@
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The Claude Code login channel: the private chat of the first allowed user (the bot owner). In a private chat the chat
/// id is the user id, so the chat is created on demand even if the owner has not written yet (sending then fails
/// unless the owner has started the bot, which surfaces as an unanswered login).
/// </summary>
public sealed class OwnerLoginChannel(ChatRegistry chats, TelegramOptions options)
{
public async Task<IUserInterface?> ResolveAsync(CancellationToken cancellationToken)
{
if (options.AllowedUserIds.Count == 0) return null;
return (await chats.GetAsync(options.AllowedUserIds[0], cancellationToken).ConfigureAwait(false)).Ui;
}
}

View File

@@ -67,7 +67,8 @@ public sealed class UpdateRouter(
var chat = await chats.GetAsync(message.Chat.Id, cancellationToken).ConfigureAwait(false);
var isCommand = ChatCommands.Parse(text) is not null;
if (!isCommand && chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
// Only plain text answers a pending question: a photo (with or without a caption) is a prompt of its own.
if (!isCommand && text.Length > 0 && message.Photo is null && chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
if (isCommand && await commands.TryHandleAsync(chat, text, cancellationToken).ConfigureAwait(false)) return;
var images = await DownloadPhotoAsync(chat, message, cancellationToken).ConfigureAwait(false);

View File

@@ -26,6 +26,14 @@ if (errors.Count > 0)
}
builder.Services.AddSingleton(options);
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves
// OwnerLoginChannel lazily (at call time, not here) to avoid a construction cycle: ChatRegistry depends on the
// session factory, which depends on the extension, which would otherwise depend back on ChatRegistry.
builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
{
LoginUi = ct => sp.GetRequiredService<OwnerLoginChannel>().ResolveAsync(ct),
});
builder.Services.AddAgenticCore(o =>
{
o.DefaultModel = options.Model;

View File

@@ -16,5 +16,15 @@ public static class DefaultPrompts
"Report failures honestly, including error output, instead of claiming success.",
];
public static readonly IReadOnlyList<string> ProjectContextFiles = ["AGENTS.md", "CLAUDE.md", ".volcanic/AGENTS.md"];
/// <summary>Preference order: per directory only the first file present is loaded.</summary>
public static readonly IReadOnlyList<string> ProjectContextFiles = ["AGENTS.md", "CLAUDE.md"];
public const string PersonaFile = "PERSONA.md";
public const string MemoryFile = "MEMORY.md";
public static string MemoryPreamble(string path) => $"""
This is your persistent memory, loaded into every session from {path}.
To remember something for future sessions, update that file with the edit or write tool. Keep it concise,
organised, and current: merge duplicates and remove entries that are no longer true.
""";
}

View File

@@ -6,7 +6,8 @@ namespace Volcanic.AgenticCore.Features.SystemPrompt;
/// <summary>
/// Composes the system prompt: base prompt, tools section (from <see cref="ITool.PromptSnippet"/>), guidelines,
/// extra sections, project context files (nearest AGENTS.md / CLAUDE.md walking up from cwd), then date and cwd.
/// extra sections, project context files (AGENTS.md, else CLAUDE.md, per directory walking up from cwd), the agent's
/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), then date and cwd. File names match case-insensitively.
/// </summary>
public sealed class SystemPromptBuilder
{
@@ -52,37 +53,68 @@ public sealed class SystemPromptBuilder
{
var context = LoadProjectContext();
if (context.Length > 0) AppendSection(sb, "project_context", context);
var persona = ReadWorkspaceFile(DefaultPrompts.PersonaFile);
if (!string.IsNullOrWhiteSpace(persona.Content)) AppendSection(sb, "persona", persona.Content);
// The memory section appears whenever the file exists, even empty, so the agent knows where to write.
var memory = ReadWorkspaceFile(DefaultPrompts.MemoryFile);
if (memory.Path is not null) AppendSection(sb, "memory", DefaultPrompts.MemoryPreamble(memory.Path) + (string.IsNullOrWhiteSpace(memory.Content) ? "" : "\n\n" + memory.Content));
}
AppendSection(sb, "environment", $"Current date: {_now():yyyy-MM-dd}\nWorking directory: {_cwd}");
return sb.ToString().TrimEnd();
}
/// <summary>Concatenates the nearest project context files found walking up from cwd (nearest first), size capped.</summary>
/// <summary>
/// Concatenates one project context file per directory walking up from cwd (nearest first): the first of
/// <see cref="DefaultPrompts.ProjectContextFiles"/> present. Size capped.
/// </summary>
public string LoadProjectContext()
{
var sb = new StringBuilder();
var dir = new DirectoryInfo(_cwd);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (dir is not null && sb.Length < MaxProjectContextBytes)
{
foreach (var name in DefaultPrompts.ProjectContextFiles)
var path = DefaultPrompts.ProjectContextFiles.Select(name => FindFile(dir.FullName, name)).FirstOrDefault(p => p is not null);
var content = path is null ? null : TryRead(path, MaxProjectContextBytes - sb.Length);
if (content is not null)
{
var path = Path.Combine(dir.FullName, name);
if (!File.Exists(path) || !seen.Add(path)) continue;
string content;
try { content = File.ReadAllText(path); }
catch (IOException) { continue; }
var remaining = MaxProjectContextBytes - sb.Length;
if (remaining <= 0) break;
if (content.Length > remaining) content = content[..remaining] + "\n[truncated]";
sb.Append("# ").Append(Path.GetRelativePath(_cwd, path)).Append('\n').Append(content.Trim()).Append("\n\n");
sb.Append("# ").Append(Path.GetRelativePath(_cwd, path!)).Append('\n').Append(content).Append("\n\n");
}
dir = dir.Parent;
}
return sb.ToString().TrimEnd();
}
/// <summary>Reads a file from cwd (name matched case-insensitively), size capped. Path is null when absent.</summary>
public (string? Path, string? Content) ReadWorkspaceFile(string name)
{
var path = FindFile(_cwd, name);
return path is null ? (null, null) : (path, TryRead(path, MaxProjectContextBytes));
}
private static string? FindFile(string dir, string name)
{
var exact = Path.Combine(dir, name);
if (File.Exists(exact)) return exact;
try
{
return Directory.EnumerateFiles(dir).FirstOrDefault(f => string.Equals(Path.GetFileName(f), name, StringComparison.OrdinalIgnoreCase));
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return null; }
}
private static string? TryRead(string path, int maxLength)
{
if (maxLength <= 0) return null;
string content;
try { content = File.ReadAllText(path); }
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return null; }
if (content.Length > maxLength) content = content[..maxLength] + "\n[truncated]";
return content.Trim();
}
private static void AppendSection(StringBuilder sb, string name, string content) =>
sb.Append('\n').Append('<').Append(name).Append(">\n").Append(content).Append("\n</").Append(name).Append(">\n");
}

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,353 @@ 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("login complete"));
var question = Assert.Single(owner.Questions);
Assert.Contains("oauth/authorize", question);
Assert.Contains(UiLoginPrompt.CodeQuestion, question);
}
[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);
}
// ---- deferring mode: every request with a login UI holds its init Start until something else is published ----
private int _loginRequests;
/// <summary>A login UI that counts how often a login flow asked for the owner.</summary>
private Func<CancellationToken, Task<IUserInterface?>> CountingLoginUi() => _ =>
{
Interlocked.Increment(ref _loginRequests);
return Task.FromResult<IUserInterface?>(new RecordingUi());
};
/// <summary>Collects the stream, noting for each OnResponse call how many Starts the consumer had already seen.</summary>
private static async Task<(List<AssistantMessageEvent> Events, List<int> StartsBeforeResponse)> CollectWithResponses(
Func<StreamOptions, IAsyncEnumerable<AssistantMessageEvent>> stream, Action<StreamOptions>? configure = null)
{
var events = new List<AssistantMessageEvent>();
var startsBeforeResponse = new List<int>();
var options = new StreamOptions
{
SessionId = "session-1",
OnResponse = _ =>
{
lock (events) startsBeforeResponse.Add(events.Count(e => e is AssistantMessageEvent.Start));
return ValueTask.CompletedTask;
},
};
await foreach (var e in stream(options)) lock (events) events.Add(e);
return (events, startsBeforeResponse);
}
private string TextFixture()
{
var fixture = Path.Combine(_root, "text.jsonl");
File.WriteAllLines(fixture, [
Fixtures.Init().GetRawText(),
"{\"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}}",
]);
return fixture;
}
[Fact]
public async Task Deferring_text_turn_publishes_one_start_after_one_response()
{
if (!Fixtures.IsPosix) return;
var api = Api(FakeClaude($"cat \"{TextFixture()}\"", tail: "exit 0"), loginUi: CountingLoginUi());
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), o, CancellationToken.None));
Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
Assert.Equal("hello there", Assert.IsType<AssistantMessageEvent.Done>(events[^1]).Message.Text);
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Deferring_tool_handoff_is_unchanged()
{
if (!Fixtures.IsPosix) return;
var api = Api(FakeClaude($"cat \"{Fixtures.FixturePath("claude-2.1.281-stream-tool-ok.jsonl")}\""), loginUi: CountingLoginUi());
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, ToolContext(), o, CancellationToken.None));
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([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Deferring_non_login_failure_after_init_publishes_the_held_start()
{
if (!Fixtures.IsPosix) return;
var api = Api(FakeClaude($"echo '{Fixtures.Init().GetRawText()}'\necho \"boom: fake failure\" >&2", tail: "exit 1"), loginUi: CountingLoginUi());
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), o, CancellationToken.None));
Assert.Equal(["Start", "Error"], events.Select(e => e.GetType().Name).ToArray());
Assert.StartsWith("[process_exit]", Assert.IsType<AssistantMessageEvent.Error>(events[^1]).Message.ErrorMessage);
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Deferring_cancellation_after_init_reports_aborted_after_the_held_start()
{
if (!Fixtures.IsPosix) return;
using var cts = new CancellationTokenSource();
var api = Api(FakeClaude($"echo '{Fixtures.Init().GetRawText()}'"), loginUi: CountingLoginUi());
var stopwatch = Stopwatch.StartNew();
// The Start is held, so cancel on the init record itself.
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"),
o with { OnProviderStreamEvent = _ => { cts.CancelAfter(100); return ValueTask.CompletedTask; } }, cts.Token));
stopwatch.Stop();
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(8), $"took {stopwatch.Elapsed}");
Assert.Equal(["Start", "Error"], events.Select(e => e.GetType().Name).ToArray());
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
Assert.Equal(StopReason.Aborted, error.Reason);
Assert.Equal("aborted", error.Message.ErrorMessage);
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Login_required_after_content_is_published_without_a_login()
{
if (!Fixtures.IsPosix) return;
var fixture = Path.Combine(_root, "expired-mid.jsonl");
File.WriteAllLines(fixture, [
Fixtures.Init().GetRawText(),
"{\"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\":\"partial\"}}}",
"{\"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\":7,\"output_tokens\":1}}",
]);
var runs = Path.Combine(_root, "runs");
var api = Api(FakeClaude($"echo run >> \"{runs}\"\ncat \"{fixture}\"", tail: "exit 1"), loginUi: CountingLoginUi());
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), o, CancellationToken.None));
Assert.Equal(["Start", "TextStart", "TextDelta", "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("partial", error.Message.Text);
Assert.Single(File.ReadAllLines(runs));
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
[Fact]
public async Task Replay_after_login_announces_one_response()
{
if (!Fixtures.IsPosix) return;
var api = Api(ExpiringClaude(), loginUi: _ => Task.FromResult<IUserInterface?>(new RecordingUi("the-code")));
var (events, responses) = await CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, ToolContext(), o, CancellationToken.None));
Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
Assert.Single(events, e => e is AssistantMessageEvent.Start);
Assert.Equal([0], responses);
Assert.Equal(2, Runs());
}
[Fact]
public async Task Owner_lookup_that_never_answers_is_unreachable()
{
if (!Fixtures.IsPosix) return;
var api = new ClaudeCodeApi(_registry, new ClaudeCodeApiOptions
{
ClaudePath = ExpiringClaude(),
GetEnv = _ => null,
LoginOptions = new ClaudeLoginOptions { OwnerTimeout = TimeSpan.FromMilliseconds(200) },
});
var outcome = await api.RunLoginFlowAsync(_ => new TaskCompletionSource<IUserInterface?>().Task, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
Assert.False(outcome.Success);
Assert.StartsWith("the owner could not be reached", outcome.Error);
}
[Fact]
public async Task Login_required_after_a_login_completed_mid_attempt_replays_without_a_second_login()
{
if (!Fixtures.IsPosix) return;
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
// The first run reports the expiry only once released (after the test's own login); later runs succeed.
var script = FakeClaude(
$"STATE=\"{state}\"\n" +
"echo run >> \"$STATE/runs\"\n" +
"if [ \"$(wc -l < \"$STATE/runs\")\" -eq 1 ]; then\n" +
" : > \"$STATE/started\"\n" +
" while [ ! -f \"$STATE/release\" ]; do sleep 0.05; done\n" +
$" cat \"{Fixtures.FixturePath("claude-2.1.281-stream-auth-expired.jsonl")}\"\n" +
$"else cat \"{Fixtures.FixturePath("claude-2.1.281-stream-tool-ok.jsonl")}\"; fi",
tail: "exit 0");
var coordinator = new ClaudeLoginCoordinator();
var api = Api(script, loginUi: CountingLoginUi(), coordinator: coordinator);
var collecting = CollectWithResponses(o => api.StreamAsync(Fixtures.Sonnet, ToolContext(), o, CancellationToken.None));
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!File.Exists(StatePath("started")) && DateTime.UtcNow < deadline) await Task.Delay(20);
Assert.True(File.Exists(StatePath("started")));
// A login elsewhere completes while this attempt is still running.
Assert.True((await coordinator.EnsureLoggedInAsync(_ => Task.FromResult(LoginOutcome.Ok), CancellationToken.None)).Success);
File.WriteAllText(StatePath("release"), "");
var (events, responses) = await collecting.WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
Assert.Equal(2, Runs());
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
}

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

@@ -0,0 +1,109 @@
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
public class ClaudeLoginCoordinatorTests
{
[Fact]
public async Task Concurrent_callers_share_one_flow()
{
var coordinator = new ClaudeLoginCoordinator();
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
var runs = 0;
Func<CancellationToken, Task<LoginOutcome>> flow = _ => { Interlocked.Increment(ref runs); return gate.Task; };
var first = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
var second = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
gate.SetResult(LoginOutcome.Ok);
Assert.True((await first).Success);
Assert.True((await second).Success);
Assert.Equal(1, runs);
}
[Fact]
public async Task Failure_reaches_every_waiter_and_the_next_call_starts_a_new_flow()
{
var coordinator = new ClaudeLoginCoordinator();
var runs = 0;
var a = coordinator.EnsureLoggedInAsync(_ => { runs++; return Task.FromResult(LoginOutcome.Failed("no code was entered")); }, CancellationToken.None);
Assert.Equal("no code was entered", (await a).Error);
var b = await coordinator.EnsureLoggedInAsync(_ => { runs++; return Task.FromResult(LoginOutcome.Ok); }, CancellationToken.None);
Assert.True(b.Success);
Assert.Equal(2, runs);
}
[Fact]
public async Task A_cancelled_waiter_does_not_cancel_the_flow()
{
var coordinator = new ClaudeLoginCoordinator();
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken flowToken = default;
Func<CancellationToken, Task<LoginOutcome>> flow = ct => { flowToken = ct; return gate.Task; };
using var cts = new CancellationTokenSource();
var cancelled = coordinator.EnsureLoggedInAsync(flow, cts.Token);
var other = coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cancelled);
Assert.False(flowToken.IsCancellationRequested);
gate.SetResult(LoginOutcome.Ok);
Assert.True((await other).Success);
}
[Fact]
public async Task A_throwing_flow_becomes_a_failed_outcome()
{
var coordinator = new ClaudeLoginCoordinator();
var outcome = await coordinator.EnsureLoggedInAsync(_ => throw new InvalidOperationException("boom"), CancellationToken.None);
Assert.False(outcome.Success);
Assert.Contains("boom", outcome.Error);
}
/// <summary>
/// Regression test for a real bug found while reviewing the design: without an unconditional suspension point
/// (<c>Task.Yield()</c>) before the slot is ever cleared, a flow that completes synchronously (as
/// <c>Task.FromResult</c> does here) runs its whole body - including the <c>finally</c> that clears
/// <c>_current</c> - inside the very statement that assigns <c>_current</c> in the first place. The clear then
/// happens before the assignment, so the assignment silently overwrites it with the already-finished task,
/// wedging the coordinator: every later call would replay that stale result and <c>runFlow</c> would never run
/// again. This drives many concurrent synchronous completions through the same coordinator to make sure the
/// slot is freed every time.
/// </summary>
[Fact]
public async Task A_synchronously_completing_flow_still_frees_its_slot_for_the_next_call()
{
var coordinator = new ClaudeLoginCoordinator();
var runs = 0;
Func<CancellationToken, Task<LoginOutcome>> flow = _ => { Interlocked.Increment(ref runs); return Task.FromResult(LoginOutcome.Ok); };
for (var i = 0; i < 50; i++)
{
var outcome = await coordinator.EnsureLoggedInAsync(flow, CancellationToken.None);
Assert.True(outcome.Success);
}
Assert.Equal(50, runs);
}
[Fact]
public async Task Generation_advances_only_on_a_successful_flow()
{
var coordinator = new ClaudeLoginCoordinator();
Assert.Equal(0, coordinator.Generation);
await coordinator.EnsureLoggedInAsync(_ => Task.FromResult(LoginOutcome.Failed("no code was entered")), CancellationToken.None);
Assert.Equal(0, coordinator.Generation);
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
var running = coordinator.EnsureLoggedInAsync(_ => gate.Task, CancellationToken.None);
Assert.Equal(0, coordinator.Generation);
gate.SetResult(LoginOutcome.Ok);
await running;
Assert.Equal(1, coordinator.Generation);
await coordinator.EnsureLoggedInAsync(_ => throw new InvalidOperationException("boom"), CancellationToken.None);
Assert.Equal(1, coordinator.Generation);
}
}

View File

@@ -0,0 +1,151 @@
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;
public void Dispose() { try { Directory.Delete(_root, true); } catch (IOException) { } }
private sealed class ScriptedPrompt(params string?[] answers) : ILoginPrompt
{
private int _next;
public List<string> Urls { get; } = [];
public List<string> Reports { get; } = [];
public Func<CancellationToken, Task>? BeforeAnswer { get; init; }
public async Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken ct)
{
Urls.Add(url);
if (BeforeAnswer is not null) await BeforeAnswer(ct);
return _next < answers.Length ? answers[_next++] : null;
}
public void Report(string message) => Reports.Add(message);
}
/// <summary>printUrl=false simulates a CLI that exits without printing a login link.</summary>
private string FakeClaude(bool printUrl = true)
{
var script = Path.Combine(_root, "claude");
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
File.WriteAllText(script, $$"""
#!/bin/sh
STATE="{{state}}"
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
n=$(cat "$STATE/n" 2>/dev/null || echo 0); n=$((n+1)); echo $n > "$STATE/n"; echo $$ > "$STATE/pid"
echo "Opening browser to sign in…"
{{(printUrl ? "echo \"If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&state=s$n\"" : "exit 3")}}
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","apiProvider":"firstParty"}'
else
echo '{"loggedIn":false}'
fi
exit 0
fi
exit 1
""".Replace("\r\n", "\n"));
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
private static IReadOnlyDictionary<string, string> Env() =>
new Dictionary<string, string> { ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin" };
private static readonly ClaudeLoginOptions Fast = new() { UrlTimeout = TimeSpan.FromSeconds(5), ExchangeTimeout = TimeSpan.FromSeconds(5) };
[Fact]
public async Task Valid_code_logs_in()
{
if (!Fixtures.IsPosix) return;
var prompt = new ScriptedPrompt(" good ");
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, CancellationToken.None);
Assert.True(outcome.Success, outcome.Error);
Assert.Equal(["https://claude.com/cai/oauth/authorize?code=true&state=s1"], prompt.Urls);
}
[Fact]
public async Task Rejected_code_gets_a_new_link()
{
if (!Fixtures.IsPosix) return;
var prompt = new ScriptedPrompt("bad", "good");
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, CancellationToken.None);
Assert.True(outcome.Success, outcome.Error);
Assert.Equal(2, prompt.Urls.Count);
Assert.EndsWith("state=s2", prompt.Urls[1]);
Assert.Single(prompt.Reports);
}
[Fact]
public async Task Three_rejected_codes_fail()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), new ScriptedPrompt("a", "b", "c", "good"), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal("the code was rejected 3 times", outcome.Error);
}
[Fact]
public async Task No_answer_fails()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), new ScriptedPrompt(), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal("no code was entered", outcome.Error);
}
[Fact]
public async Task Missing_url_fails()
{
if (!Fixtures.IsPosix) return;
var outcome = await ClaudeLogin.RunAsync(FakeClaude(printUrl: false), Env(), new ScriptedPrompt("good"), Fast, CancellationToken.None);
Assert.False(outcome.Success);
Assert.StartsWith("claude auth login printed no login URL", outcome.Error);
}
[Fact]
public async Task Cancellation_throws()
{
if (!Fixtures.IsPosix) return;
using var cts = new CancellationTokenSource();
var prompt = new ScriptedPrompt("good") { BeforeAnswer = async ct => { cts.Cancel(); await Task.Delay(Timeout.Infinite, ct); } };
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, Fast, cts.Token));
}
[Fact]
public async Task Unanswered_prompt_times_out_as_no_code_and_ends_the_login_process()
{
if (!Fixtures.IsPosix) return;
// The prompt ignores its token entirely, like a host dialog that never returns.
var prompt = new ScriptedPrompt("good") { BeforeAnswer = _ => Task.Delay(Timeout.Infinite, CancellationToken.None) };
var options = Fast with { CodeTimeout = TimeSpan.FromMilliseconds(300) };
var outcome = await ClaudeLogin.RunAsync(FakeClaude(), Env(), prompt, options, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
Assert.False(outcome.Success);
Assert.Equal("no code was entered", outcome.Error);
Assert.Single(prompt.Urls);
if (!OperatingSystem.IsLinux()) return;
var pid = File.ReadAllText(Path.Combine(_root, "state", "pid")).Trim();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (IsAlive(pid) && DateTime.UtcNow < deadline) await Task.Delay(25);
Assert.False(IsAlive(pid), $"claude auth login (pid {pid}) is still running");
}
/// <summary>Alive unless gone from /proc or a zombie awaiting its reaper.</summary>
private static bool IsAlive(string pid)
{
try
{
var stat = File.ReadAllText($"/proc/{pid}/stat");
return stat[(stat.LastIndexOf(')') + 2)] != 'Z';
}
catch (IOException) { return false; }
}
}

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

@@ -4,3 +4,7 @@ These files are real `claude --print --output-format stream-json` captures and `
copied from [chem/pi-claude-code-provider](https://github.com/chem/pi-claude-code-provider)
(`test/support/captured/`, MIT License, Copyright (c) 2026 chem). They drive the deterministic
stream-mapping tests for the ClaudeCode provider extension so no test needs a Claude subscription.
`claude-2.1.281-stream-auth-expired.jsonl` and `claude-2.1.281-stream-auth-login.jsonl` are hand-written
(not captured) from Claude's documented 401 / "Please run /login" output, to exercise expired-session
detection without needing an actually-expired subscription.

View File

@@ -0,0 +1,4 @@
{"type":"system","subtype":"init","cwd":"/project","session_id":"00000000-0000-4000-8000-000000000000","tools":["mcp__pi__probe"],"mcp_servers":[{"name":"pi","status":"connected","source":"dynamic"}],"model":"claude-sonnet-5","permissionMode":"dontAsk","slash_commands":[],"apiKeySource":"none","claude_code_version":"2.1.281","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":[],"plugins":[],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1","mcp_read_resource_v1","mcp_tool_ui_meta_v1"],"analytics_disabled":true,"product_feedback_disabled":true,"uuid":"00000000-0000-4000-8000-000000000001","messaging_socket_path":"/socket","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","per_turn_effort_active":false,"view_mode":"default"}
{"type":"system","subtype":"status","status":"requesting","session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000002"}
{"type":"assistant","message":{"id":"00000000-0000-4000-8000-00000000000a","model":"<synthetic>","role":"assistant","type":"message","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"OAuth token has expired. Please obtain a new token or refresh your existing token.\"}}"}]},"error":"authentication_failed","session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000003"}
{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"duration_ms":312,"num_turns":1,"result":"Failed to authenticate. API Error: 401 OAuth token has expired. Please obtain a new token or refresh your existing token.","stop_reason":"stop_sequence","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"uuid":"00000000-0000-4000-8000-000000000004"}

View File

@@ -0,0 +1,3 @@
{"type":"system","subtype":"init","cwd":"/project","session_id":"00000000-0000-4000-8000-000000000000","tools":["mcp__pi__probe"],"mcp_servers":[{"name":"pi","status":"connected","source":"dynamic"}],"model":"claude-sonnet-5","permissionMode":"dontAsk","slash_commands":[],"apiKeySource":"none","claude_code_version":"2.1.281","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":[],"plugins":[],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1","mcp_read_resource_v1","mcp_tool_ui_meta_v1"],"analytics_disabled":true,"product_feedback_disabled":true,"uuid":"00000000-0000-4000-8000-000000000001","messaging_socket_path":"/socket","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","per_turn_effort_active":false,"view_mode":"default"}
{"type":"assistant","message":{"id":"00000000-0000-4000-8000-00000000000a","model":"<synthetic>","role":"assistant","type":"message","content":[{"type":"text","text":"Invalid API key · Please run /login"}]},"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000003"}
{"type":"result","subtype":"success","is_error":true,"duration_ms":120,"num_turns":1,"result":"Invalid API key · Please run /login","stop_reason":"stop_sequence","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"uuid":"00000000-0000-4000-8000-000000000004"}

View File

@@ -0,0 +1,176 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
/// <summary>Drives <see cref="LoginCommand"/> directly (not through activation), matching <see cref="DoctorCommandTests"/>.</summary>
[Collection(FakeScripts.Collection)]
public class LoginCommandTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("claude-login-command-test-").FullName;
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
/// <summary>Records what the command sends; everything else is unsupported so a misuse during the command is loud.</summary>
private sealed class FakeExtensionApi : IExtensionApi
{
public List<(CustomMessage Message, MessageDelivery Delivery, bool TriggerTurn)> Sent { get; } = [];
public string ExtensionName => "claude-code-provider";
public ILogger Logger => NullLogger.Instance;
public IEventBus Events => throw new NotSupportedException();
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent => throw new NotSupportedException();
public void RegisterTool(ITool tool) => throw new NotSupportedException();
public void RegisterCommand(string name, CommandDefinition command) => throw new NotSupportedException();
public void RegisterProvider(ProviderRegistration provider) => throw new NotSupportedException();
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => Sent.Add((message, delivery, triggerTurn));
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
public string? GetSessionName() => throw new NotSupportedException();
public void SetSessionName(string? name) => throw new NotSupportedException();
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
/// <summary>A minimal command context: only <see cref="HasUi"/> and <see cref="Ui"/> matter to the command.</summary>
private sealed class FakeContext(IUserInterface? ui) : IExtensionCommandContext
{
public string Cwd => "/";
public string SessionId => "s";
public bool HasUi => ui is not null;
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry => throw new NotSupportedException();
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => NullLogger.Instance;
public bool IsIdle() => true;
public void Abort() { }
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "";
public void Compact(CompactOptions? options = null) { }
public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
}
/// <summary>A fake claude handling only <c>auth login</c> and <c>auth status</c> (as in <c>ClaudeLoginTests</c>).</summary>
private string FakeClaude()
{
var script = Path.Combine(_root, "claude");
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
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=x"
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
exit 1
""".Replace("\r\n", "\n"));
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
private ClaudeCodeApi Claude(string claudePath, ClaudeLoginCoordinator? coordinator = null) => new(new SessionRegistry(), new ClaudeCodeApiOptions
{
ClaudePath = claudePath,
Coordinator = coordinator ?? new ClaudeLoginCoordinator(),
GetEnv = _ => null,
ParentEnvironment = new Dictionary<string, string?> { ["PATH"] = Environment.GetEnvironmentVariable("PATH") },
});
[Fact]
public async Task Without_ui_the_command_explains_it_needs_one()
{
var api = new FakeExtensionApi();
var command = LoginCommand.Create(api, Claude("/nonexistent/claude"));
await command.Handler("", new FakeContext(null), CancellationToken.None);
var (message, delivery, triggerTurn) = Assert.Single(api.Sent);
Assert.Equal(LoginCommand.Name, message.CustomType);
Assert.Contains("needs an interactive UI", message.Text);
Assert.Equal(MessageDelivery.Steer, delivery);
Assert.False(triggerTurn);
}
[Fact]
public async Task With_ui_it_runs_the_login_through_that_ui()
{
if (!Fixtures.IsPosix) return;
var api = new FakeExtensionApi();
var command = LoginCommand.Create(api, Claude(FakeClaude()));
var ui = new RecordingUi("good");
await command.Handler("", new FakeContext(ui), CancellationToken.None);
Assert.Contains(ui.Notes, n => n.Message.Contains("login complete"));
Assert.Empty(api.Sent);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Joining_a_login_driven_elsewhere_reports_its_outcome_here(bool success)
{
var coordinator = new ClaudeLoginCoordinator();
var gate = new TaskCompletionSource<LoginOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
var elsewhere = coordinator.EnsureLoggedInAsync(_ => gate.Task, CancellationToken.None);
var command = LoginCommand.Create(new FakeExtensionApi(), Claude("/nonexistent/claude", coordinator));
var ui = new RecordingUi("never-asked");
var running = command.Handler("", new FakeContext(ui), CancellationToken.None);
gate.SetResult(success ? LoginOutcome.Ok : LoginOutcome.Failed("no code was entered"));
await running.WaitAsync(TimeSpan.FromSeconds(10));
await elsewhere;
Assert.Empty(ui.Questions); // the other flow's UI drove the login
var (message, kind) = Assert.Single(ui.Notes);
Assert.Contains("already in progress elsewhere", message);
if (success)
{
Assert.Contains("succeeded", message);
Assert.Equal(NotifyKind.Info, kind);
}
else
{
Assert.Contains("failed: no code was entered", message);
Assert.Equal(NotifyKind.Error, kind);
}
}
}

View File

@@ -0,0 +1,82 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
/// <summary>
/// The Telegram host (and any other host that wants a per-session <see cref="ClaudeCodeProviderOptions.LoginUi"/>)
/// relies on <c>ActivatorUtilities.CreateInstance</c> resolving <see cref="ClaudeCodeProviderExtension"/>'s optional
/// <see cref="ClaudeCodeProviderOptions"/> constructor parameter from a singleton registered in the service
/// provider - the exact construction path <c>ExtensionRegistration.FromType&lt;T&gt;</c> uses per session (see
/// <c>Features/Extensions/ExtensionRegistration.cs</c>). This pins that assumption down so a future change to the
/// constructor's shape, or to ActivatorUtilities' own resolution rules, fails here instead of silently dropping
/// <c>LoginUi</c> in production.
/// </summary>
public class ProviderExtensionActivatorInjectionTests
{
/// <summary>Records provider and command registrations; only what activation touches.</summary>
private sealed class MinimalExtensionApi : IExtensionApi
{
public List<ProviderRegistration> Providers { get; } = [];
public Dictionary<string, CommandDefinition> Commands { get; } = new();
public string ExtensionName => "claude-code-provider";
public ILogger Logger => NullLogger.Instance;
public IEventBus Events => throw new NotSupportedException();
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent => new Subscription();
public void RegisterTool(ITool tool) => throw new NotSupportedException();
public void RegisterCommand(string name, CommandDefinition command) => Commands[name] = command;
public void RegisterProvider(ProviderRegistration provider) => Providers.Add(provider);
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) { }
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
public string? GetSessionName() => throw new NotSupportedException();
public void SetSessionName(string? name) => throw new NotSupportedException();
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
private sealed class Subscription : IDisposable { public void Dispose() { } }
}
[Fact]
public async Task ActivatorUtilities_resolves_the_extensions_optional_options_parameter_from_di()
{
var services = new ServiceCollection();
services.AddSingleton(new ClaudeCodeProviderOptions { ClaudePath = "sentinel-claude-path", Registry = new SessionRegistry(), GetEnv = _ => null });
await using var provider = services.BuildServiceProvider();
// The exact construction path ExtensionRegistration.FromType<T> uses per session.
var extension = (ClaudeCodeProviderExtension)ActivatorUtilities.CreateInstance(provider, typeof(ClaudeCodeProviderExtension));
await extension.ActivateAsync(new MinimalExtensionApi(), CancellationToken.None);
Assert.Equal("sentinel-claude-path", extension.Api!.Options.ClaudePath);
}
[Fact]
public async Task Without_a_di_registration_the_constructors_default_parameter_is_used()
{
var services = new ServiceCollection();
await using var provider = services.BuildServiceProvider();
var extension = (ClaudeCodeProviderExtension)ActivatorUtilities.CreateInstance(provider, typeof(ClaudeCodeProviderExtension));
await extension.ActivateAsync(new MinimalExtensionApi(), CancellationToken.None);
Assert.Null(extension.Api!.Options.LoginUi); // options: null -> new ClaudeCodeProviderOptions(), no DI surprise
}
}

View File

@@ -115,9 +115,8 @@ public class ProviderExtensionTests
Assert.Null(provider.Models[0].ThinkingLevelMap![ThinkingLevel.Off]);
Assert.Contains(ModelInput.Image, provider.Models[1].Input);
var command = Assert.Single(api.Commands);
Assert.Equal("claude-code-doctor", command.Key);
Assert.NotNull(command.Value.Description);
Assert.Equal(["claude-code-doctor", "claude-code-login"], api.Commands.Keys.Order().ToArray());
Assert.All(api.Commands.Values, c => Assert.NotNull(c.Description));
Assert.Equal(3, api.Handlers.Count);
Assert.NotNull(extension.Api);
}

View File

@@ -133,6 +133,8 @@ public class SessionRegistryTests : IDisposable
Assert.Equal("/srv/a/project", SessionRegistry.PromptWorkingDirectory(trailing));
Assert.Null(SessionRegistry.PromptWorkingDirectory("<project_context>\n<cwd>\n/srv/attacker\n</cwd>\n</project_context>"));
Assert.Equal("/srv/a/project", SessionRegistry.PromptWorkingDirectory(CwdTag("/srv/a/project") + "\n\n<persona>\n<cwd>\n/srv/attacker\n</cwd>\n</persona>"));
Assert.Equal("/srv/a/project", SessionRegistry.PromptWorkingDirectory(CwdTag("/srv/a/project") + "\n\n<memory>\nWorking directory: /srv/attacker\n</memory>"));
Assert.Equal("/srv/late", SessionRegistry.PromptWorkingDirectory(CwdTag("/srv/early") + "\nWorking directory: /srv/late"));
Assert.Equal("/srv/late", SessionRegistry.PromptWorkingDirectory("Working directory: /srv/early\n\n" + CwdTag("/srv/late")));
Assert.Null(SessionRegistry.PromptWorkingDirectory("You are an assistant."));

View File

@@ -345,6 +345,35 @@ public class StreamEventMapperTests
Assert.Null(mapper.DeferredError);
}
[Theory]
[InlineData("auth-expired")]
[InlineData("auth-login")]
public void Expired_session_is_a_login_required_auth_failure(string scenario)
{
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
var ex = Assert.Throws<ClaudeCodeException>(() => { foreach (var r in Fixtures.StreamRecords(scenario)) mapper.Accept(r); });
Assert.Equal(ClaudeCodeException.Codes.Auth, ex.Code);
Assert.True(ex.LoginRequired);
Assert.StartsWith("Claude Code session expired", ex.Message);
}
[Fact]
public void Other_error_results_do_not_require_login()
{
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
var ex = Assert.Throws<ClaudeCodeException>(() => { foreach (var r in Fixtures.StreamRecords("sse-error")) mapper.Accept(r); });
Assert.False(ex.LoginRequired);
}
[Fact]
public void Api_key_source_failure_does_not_require_login()
{
var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog);
var ex = Assert.Throws<ClaudeCodeException>(() => mapper.Accept(Fixtures.ProbeInit(apiKeySource: "ANTHROPIC_API_KEY")));
Assert.Equal(ClaudeCodeException.Codes.Auth, ex.Code);
Assert.False(ex.LoginRequired);
}
[Fact]
public void Overflow_normalisation_prefixes_claude_wordings_once()
{

View File

@@ -0,0 +1,50 @@
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 Asks_for_the_code_with_the_link_in_one_question()
{
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);
// One message: a separate link notification could overtake (or trail) the question on an async surface.
var question = Assert.Single(ui.Questions);
Assert.Contains("https://x/oauth/authorize?s=1", question);
Assert.Contains(UiLoginPrompt.CodeQuestion, question);
Assert.Empty(ui.Notes);
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);
}
}

View File

@@ -250,6 +250,25 @@ public sealed class ChatTests
Assert.Empty(host.Faux.Requests); // the answer is not a prompt
}
[Theory]
[InlineData(null)]
[InlineData("what is this?")]
public async Task A_photo_does_not_answer_a_pending_input(string? caption)
{
await using var host = new ChatHost();
await host.SendAsync("/status");
var chat = await host.ChatAsync();
var answer = chat.Ui.InputAsync("Your name?", "name", null, CancellationToken.None);
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the question");
await host.Router.RouteAsync(Updates.Photo(caption), CancellationToken.None);
Assert.True(chat.Ui.IsWaitingForInput);
Assert.False(answer.IsCompleted);
await host.SendAsync("Ada");
Assert.Equal("Ada", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Fact]
public async Task Buttons_from_strangers_are_ignored()
{

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

@@ -0,0 +1,45 @@
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;
public sealed class OwnerLoginTests
{
[Fact]
public async Task Login_link_and_code_prompt_go_to_the_owner_chat_and_the_reply_answers_it()
{
await using var host = new ChatHost();
var channel = new OwnerLoginChannel(host.Chats, host.Options);
var ui = await channel.ResolveAsync(CancellationToken.None);
Assert.NotNull(ui);
var ask = new UiLoginPrompt(ui!).AskForCodeAsync("https://claude.com/cai/oauth/authorize?state=t", 1, CancellationToken.None);
var chat = await host.ChatAsync();
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the login code question");
// A plain-text reply from the owner (not a command, no /-prefix) answers the pending InputAsync directly;
// it never becomes a prompt to the model.
await host.SendAsync("pasted-code");
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Empty(host.Faux.Requests);
// The link and the code question are one message, so they can never arrive out of order.
var prompt = Assert.Single(host.Bot.Sent, s => s.ChatId.Identifier == Updates.AllowedUser
&& s.Text.Contains("oauth/authorize?state=t", StringComparison.Ordinal));
Assert.Contains(UiLoginPrompt.CodeQuestion, prompt.Text);
Assert.IsType<ForceReplyMarkup>(prompt.ReplyMarkup);
}
[Fact]
public async Task No_allowed_users_means_no_owner_to_resolve()
{
await using var host = new ChatHost();
host.Options.AllowedUserIds.Clear();
var channel = new OwnerLoginChannel(host.Chats, host.Options);
Assert.Null(await channel.ResolveAsync(CancellationToken.None));
}
}

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));

View File

@@ -25,6 +25,20 @@ public static class Updates
},
};
public static Update Photo(string? caption = null, long userId = AllowedUser) => new()
{
Id = Interlocked.Increment(ref _nextUpdateId),
Message = new Message
{
Id = Interlocked.Increment(ref _nextMessageId),
From = new User { Id = userId, FirstName = "Test" },
Chat = new Chat { Id = userId, Type = ChatType.Private },
Photo = [new PhotoSize { FileId = "photo", FileUniqueId = "photo-u", Width = 10, Height = 10 }],
Caption = caption,
Date = DateTime.UtcNow,
},
};
public static Update WithId(int id) => new() { Id = id, Message = Text("#" + id).Message };
public static Update Callback(string data, long chatId, long userId = AllowedUser) => new()

View File

@@ -50,6 +50,44 @@ public class SystemPromptBuilderTests
var narrow = builder.Render(options);
Assert.DoesNotContain("<tools>", narrow);
Assert.DoesNotContain("project_context", narrow);
Assert.DoesNotContain("<memory>", narrow);
}
finally { Directory.Delete(dir, true); }
}
[Fact]
public void Loads_AGENTS_md_in_preference_to_CLAUDE_md_then_persona_and_memory()
{
var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "agents.md"), "Agent rules");
File.WriteAllText(Path.Combine(dir, "CLAUDE.md"), "Claude rules");
File.WriteAllText(Path.Combine(dir, "persona.md"), "Dry wit.");
File.WriteAllText(Path.Combine(dir, "memory.md"), "- User prefers tabs");
try
{
var builder = new SystemPromptBuilder(dir, () => [], () => DateTimeOffset.UnixEpoch);
var prompt = builder.Render(builder.DefaultOptions("BASE"));
Assert.Contains("Agent rules", prompt);
Assert.DoesNotContain("Claude rules", prompt);
Assert.Contains("<persona>\nDry wit.\n</persona>", prompt);
Assert.Contains($"from {Path.Combine(dir, "memory.md")}", prompt);
Assert.Contains("- User prefers tabs\n</memory>", prompt);
var order = new[] { "<project_context>", "<persona>", "<memory>", "<environment>" }.Select(t => prompt.IndexOf(t, StringComparison.Ordinal)).ToList();
Assert.Equal(order.Order(), order);
File.Delete(Path.Combine(dir, "agents.md"));
File.WriteAllText(Path.Combine(dir, "memory.md"), "");
prompt = builder.Render(builder.DefaultOptions("BASE"));
Assert.Contains("Claude rules", prompt);
Assert.Contains("<memory>", prompt);
File.Delete(Path.Combine(dir, "persona.md"));
File.Delete(Path.Combine(dir, "memory.md"));
prompt = builder.Render(builder.DefaultOptions("BASE"));
Assert.DoesNotContain("<persona>", prompt);
Assert.DoesNotContain("<memory>", prompt);
}
finally { Directory.Delete(dir, true); }
}