345 lines
14 KiB
C#
345 lines
14 KiB
C#
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Worker;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Prime;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using ClaudeDo.Worker.Tickets;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Prime;
|
|
|
|
public class PrimeRunnerTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly string _patDir = Path.Combine(Path.GetTempPath(), "cdo-prime-pat-" + Guid.NewGuid().ToString("N"));
|
|
|
|
public void Dispose()
|
|
{
|
|
_db.Dispose();
|
|
try { Directory.Delete(_patDir, recursive: true); } catch { /* best effort */ }
|
|
}
|
|
|
|
private TicketSystemConfig Tickets(bool configured)
|
|
{
|
|
var pat = new TicketPatStore(new DpapiTokenStore(Path.Combine(_patDir, "ticket.pat")));
|
|
var worker = new WorkerConfig();
|
|
if (configured)
|
|
{
|
|
worker.TicketApiBaseUrl = "http://api.local";
|
|
pat.Store.Save("tsp_x");
|
|
}
|
|
return new TicketSystemConfig(worker, pat);
|
|
}
|
|
|
|
private sealed class FakeClock : IPrimeClock
|
|
{
|
|
public DateTimeOffset Now { get; set; } = new DateTimeOffset(2026, 6, 3, 8, 0, 0, TimeSpan.FromHours(2));
|
|
}
|
|
|
|
private sealed class FakeClaudeProcess : IClaudeProcess
|
|
{
|
|
private readonly TimeSpan _delay;
|
|
private readonly int _exitCode;
|
|
private readonly string[] _emitLines;
|
|
private readonly string? _result;
|
|
private readonly Func<IReadOnlyList<string>, int>? _exitCodeFor;
|
|
|
|
public IReadOnlyList<string>? LastArguments { get; private set; }
|
|
public string? LastPrompt { get; private set; }
|
|
public List<IReadOnlyList<string>> AllArguments { get; } = [];
|
|
|
|
public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0, string[]? emitLines = null, string? result = null,
|
|
Func<IReadOnlyList<string>, int>? exitCodeFor = null)
|
|
{
|
|
_delay = delay;
|
|
_exitCode = exitCode;
|
|
_emitLines = emitLines ?? [];
|
|
_result = result;
|
|
_exitCodeFor = exitCodeFor;
|
|
}
|
|
|
|
public async Task<RunResult> RunAsync(
|
|
IReadOnlyList<string> arguments,
|
|
string prompt,
|
|
string workingDirectory,
|
|
Func<string, Task> onStdoutLine,
|
|
CancellationToken ct)
|
|
{
|
|
AllArguments.Add(arguments);
|
|
LastArguments = arguments;
|
|
LastPrompt = prompt;
|
|
|
|
if (_delay > TimeSpan.Zero)
|
|
await Task.Delay(_delay, ct);
|
|
|
|
foreach (var line in _emitLines)
|
|
await onStdoutLine(line);
|
|
|
|
var exitCode = _exitCodeFor?.Invoke(arguments) ?? _exitCode;
|
|
return new RunResult { ExitCode = exitCode, ResultMarkdown = _result ?? (exitCode == 0 ? "ok" : null) };
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingPrimeBroadcaster : IPrimeBroadcaster
|
|
{
|
|
public int StartedCount { get; private set; }
|
|
public List<string> Lines { get; } = [];
|
|
public List<bool> FinishedResults { get; } = [];
|
|
|
|
public Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) => Task.CompletedTask;
|
|
|
|
public Task PrepStartedAsync() { StartedCount++; return Task.CompletedTask; }
|
|
public Task PrepLineAsync(string line) { Lines.Add(line); return Task.CompletedTask; }
|
|
public Task PrepFinishedAsync(bool success) { FinishedResults.Add(success); return Task.CompletedTask; }
|
|
}
|
|
|
|
private PrimeRunner NewRunner(TimeSpan claudeDelay = default, int exitCode = 0) =>
|
|
NewRunner(new FakeClaudeProcess(claudeDelay, exitCode), new RecordingPrimeBroadcaster());
|
|
|
|
private PrimeRunner NewRunner(FakeClaudeProcess claude, IPrimeBroadcaster broadcaster, bool ticketsConfigured = false) =>
|
|
new PrimeRunner(
|
|
claude,
|
|
_db.CreateFactory(),
|
|
new FakeClock(),
|
|
NullLogger<PrimeRunner>.Instance,
|
|
broadcaster,
|
|
Tickets(ticketsConfigured));
|
|
|
|
private static PrimeScheduleDto DefaultSchedule() =>
|
|
new(Guid.Empty, 0, TimeSpan.Zero, true, null, null, PrimeActionKind.FillMyDay);
|
|
|
|
private static PrimeScheduleDto Schedule(PrimeActionKind kind, string? promptOverride = null) =>
|
|
new(Guid.Empty, 0, TimeSpan.Zero, true, null, promptOverride, kind);
|
|
|
|
[Fact]
|
|
public async Task FireAsync_returns_success_when_claude_exits_zero()
|
|
{
|
|
var runner = NewRunner();
|
|
var outcome = await runner.FireAsync(DefaultSchedule(), CancellationToken.None);
|
|
|
|
Assert.True(outcome.Success);
|
|
Assert.Equal("Daily prep complete", outcome.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FireAsync_returns_failure_when_claude_exits_nonzero()
|
|
{
|
|
var runner = NewRunner(exitCode: 1);
|
|
var outcome = await runner.FireAsync(DefaultSchedule(), CancellationToken.None);
|
|
|
|
Assert.False(outcome.Success);
|
|
Assert.Contains("exit code", outcome.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FireAsync_returns_already_running_when_gate_held()
|
|
{
|
|
var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
|
|
var schedule = DefaultSchedule();
|
|
|
|
var first = runner.FireAsync(schedule, CancellationToken.None);
|
|
var second = await runner.FireAsync(schedule, CancellationToken.None);
|
|
|
|
Assert.False(second.Success);
|
|
Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
|
|
await first;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FireAsync_streams_started_lines_and_finished()
|
|
{
|
|
var broadcaster = new RecordingPrimeBroadcaster();
|
|
var claude = new FakeClaudeProcess(emitLines: ["{\"a\":1}", "{\"b\":2}"], exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, broadcaster);
|
|
var schedule = DefaultSchedule();
|
|
|
|
var outcome = await runner.FireAsync(schedule, CancellationToken.None);
|
|
|
|
Assert.True(outcome.Success);
|
|
Assert.Equal(1, broadcaster.StartedCount);
|
|
Assert.Equal(new[] { "{\"a\":1}", "{\"b\":2}" }, broadcaster.Lines);
|
|
Assert.Single(broadcaster.FinishedResults);
|
|
Assert.True(broadcaster.FinishedResults[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FireAsync_writes_last_run_to_prep_log_file()
|
|
{
|
|
var path = DailyPrepPrompt.LogPath();
|
|
if (File.Exists(path)) File.Delete(path);
|
|
|
|
var claude = new FakeClaudeProcess(emitLines: new[] { "lineA", "lineB" }, exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
|
|
await runner.FireAsync(DefaultSchedule(), CancellationToken.None);
|
|
|
|
var contents = await File.ReadAllTextAsync(path);
|
|
Assert.Contains("lineA", contents);
|
|
Assert.Contains("lineB", contents);
|
|
|
|
// Truncation: a second run with different lines replaces the file.
|
|
var claude2 = new FakeClaudeProcess(emitLines: new[] { "lineC" }, exitCode: 0, result: "ok");
|
|
var runner2 = NewRunner(claude2, new RecordingPrimeBroadcaster());
|
|
await runner2.FireAsync(DefaultSchedule(), CancellationToken.None);
|
|
var after = await File.ReadAllTextAsync(path);
|
|
Assert.DoesNotContain("lineA", after);
|
|
Assert.Contains("lineC", after);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ping_runs_a_single_turn_with_no_mcp_servers()
|
|
{
|
|
var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
|
|
|
|
var outcome = await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
|
|
|
|
Assert.True(outcome.Success);
|
|
Assert.Contains("--strict-mcp-config", claude.LastArguments!);
|
|
var turnsIndex = claude.LastArguments!.ToList().IndexOf("--max-turns");
|
|
Assert.Equal("1", claude.LastArguments[turnsIndex + 1]);
|
|
Assert.DoesNotContain(DailyPrepPrompt.SetMyDayTool, claude.LastArguments);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ping_writes_no_prep_log_and_raises_no_prep_events()
|
|
{
|
|
var path = DailyPrepPrompt.LogPath();
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
await File.WriteAllTextAsync(path, "previous fill-my-day run");
|
|
|
|
var broadcaster = new RecordingPrimeBroadcaster();
|
|
var claude = new FakeClaudeProcess(emitLines: ["ping-line"], exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, broadcaster);
|
|
|
|
await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
|
|
|
|
Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path));
|
|
Assert.Equal(0, broadcaster.StartedCount);
|
|
Assert.Empty(broadcaster.Lines);
|
|
Assert.Empty(broadcaster.FinishedResults);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Custom_sends_the_prompt_override_verbatim_with_claudedo_mcp_only()
|
|
{
|
|
var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
|
|
|
|
var outcome = await runner.FireAsync(
|
|
Schedule(PrimeActionKind.Custom, "Queue the three oldest idle tasks."),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(outcome.Success);
|
|
Assert.Equal("Queue the three oldest idle tasks.", claude.LastPrompt);
|
|
Assert.Contains("--allowedTools", claude.LastArguments!);
|
|
Assert.Contains("mcp__claudedo", claude.LastArguments!);
|
|
Assert.DoesNotContain("--strict-mcp-config", claude.LastArguments!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Custom_writes_no_prep_log_and_raises_no_prep_events()
|
|
{
|
|
var path = DailyPrepPrompt.LogPath();
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
await File.WriteAllTextAsync(path, "previous fill-my-day run");
|
|
|
|
var broadcaster = new RecordingPrimeBroadcaster();
|
|
var claude = new FakeClaudeProcess(emitLines: ["custom-line"], exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, broadcaster);
|
|
|
|
await runner.FireAsync(Schedule(PrimeActionKind.Custom, "do a thing"), CancellationToken.None);
|
|
|
|
Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path));
|
|
Assert.Equal(0, broadcaster.StartedCount);
|
|
Assert.Empty(broadcaster.FinishedResults);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FillMyDay_still_appends_the_prompt_override()
|
|
{
|
|
var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
|
|
|
|
await runner.FireAsync(
|
|
Schedule(PrimeActionKind.FillMyDay, "Prefer the LogX tasks."),
|
|
CancellationToken.None);
|
|
|
|
Assert.Contains("Prefer the LogX tasks.", claude.LastPrompt);
|
|
Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.LastArguments!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportTickets_runs_the_import_then_the_day_plan_in_one_prep_session()
|
|
{
|
|
var broadcaster = new RecordingPrimeBroadcaster();
|
|
var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, broadcaster, ticketsConfigured: true);
|
|
|
|
var outcome = await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None);
|
|
|
|
Assert.True(outcome.Success);
|
|
Assert.Equal(2, claude.AllArguments.Count);
|
|
Assert.Contains(TicketImportPrompt.ImportTool, claude.AllArguments[0]);
|
|
Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.AllArguments[1]);
|
|
Assert.Equal(1, broadcaster.StartedCount);
|
|
Assert.Equal([true], broadcaster.FinishedResults);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportTickets_failed_import_still_plans_the_day_and_reports_the_failure()
|
|
{
|
|
var broadcaster = new RecordingPrimeBroadcaster();
|
|
var claude = new FakeClaudeProcess(exitCodeFor: args => args.Contains(TicketImportPrompt.ImportTool) ? 1 : 0);
|
|
var runner = NewRunner(claude, broadcaster, ticketsConfigured: true);
|
|
|
|
var outcome = await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None);
|
|
|
|
Assert.Equal(2, claude.AllArguments.Count);
|
|
Assert.False(outcome.Success);
|
|
Assert.Contains("Ticket import failed", outcome.Message);
|
|
Assert.Contains("Daily prep complete", outcome.Message);
|
|
Assert.Equal([false], broadcaster.FinishedResults);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportTickets_without_a_ticket_system_is_plain_fill_my_day()
|
|
{
|
|
var claude = new FakeClaudeProcess(exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, new RecordingPrimeBroadcaster(), ticketsConfigured: false);
|
|
|
|
var outcome = await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None);
|
|
|
|
Assert.True(outcome.Success);
|
|
var only = Assert.Single(claude.AllArguments);
|
|
Assert.Contains(DailyPrepPrompt.SetMyDayTool, only);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportTickets_writes_both_runs_into_one_prep_log()
|
|
{
|
|
var path = DailyPrepPrompt.LogPath();
|
|
if (File.Exists(path)) File.Delete(path);
|
|
var claude = new FakeClaudeProcess(emitLines: ["step-line"], exitCode: 0, result: "ok");
|
|
var runner = NewRunner(claude, new RecordingPrimeBroadcaster(), ticketsConfigured: true);
|
|
|
|
await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None);
|
|
|
|
var lines = (await File.ReadAllLinesAsync(path)).Count(l => l.Contains("step-line"));
|
|
Assert.Equal(2, lines);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Gate_is_shared_across_kinds()
|
|
{
|
|
var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
|
|
|
|
var first = runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None);
|
|
var second = await runner.FireAsync(Schedule(PrimeActionKind.FillMyDay), CancellationToken.None);
|
|
|
|
Assert.False(second.Success);
|
|
Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
|
|
await first;
|
|
}
|
|
}
|