Merge branch 'main' of https://gitea.tes.gd/admin/VolcanicAgenticCore
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
921
docs/superpowers/plans/2026-09-25-claude-code-relogin.md
Normal file
921
docs/superpowers/plans/2026-09-25-claude-code-relogin.md
Normal 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"`
|
||||
201
docs/superpowers/specs/2026-09-25-claude-code-relogin-design.md
Normal file
201
docs/superpowers/specs/2026-09-25-claude-code-relogin-design.md
Normal 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`.
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,15 @@ public sealed record ClaudeCodeApiOptions
|
||||
|
||||
/// <summary>The MCP server name the bridge is registered under (tools become <c>mcp__<name>__<alias></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}",
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, _) =>
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -97,8 +97,8 @@ public sealed partial class SessionRegistry
|
||||
|
||||
/// <summary>
|
||||
/// The working directory a system prompt states: the last <c><cwd></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)]
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
|
||||
|
||||
[Collection(FakeScripts.Collection)]
|
||||
public class ClaudeInstallationTests
|
||||
{
|
||||
[Fact]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T></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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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."));
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
45
tests/Volcanic.AgenticCore.Telegram.Tests/OwnerLoginTests.cs
Normal file
45
tests/Volcanic.AgenticCore.Telegram.Tests/OwnerLoginTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user