using ClaudeDo.Data.Models; using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; namespace ClaudeDo.Worker.Tests.Prime; public class PrimeRunnerTests : IDisposable { private readonly DbFixture _db = new(); public void Dispose() => _db.Dispose(); 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; public IReadOnlyList? LastArguments { get; private set; } public string? LastPrompt { get; private set; } public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0, string[]? emitLines = null, string? result = null) { _delay = delay; _exitCode = exitCode; _emitLines = emitLines ?? []; _result = result; } public async Task RunAsync( IReadOnlyList arguments, string prompt, string workingDirectory, Func onStdoutLine, CancellationToken ct) { LastArguments = arguments; LastPrompt = prompt; if (_delay > TimeSpan.Zero) await Task.Delay(_delay, ct); foreach (var line in _emitLines) await onStdoutLine(line); return new RunResult { ExitCode = _exitCode, ResultMarkdown = _result ?? (_exitCode == 0 ? "ok" : null) }; } } private sealed class RecordingPrimeBroadcaster : IPrimeBroadcaster { public int StartedCount { get; private set; } public List Lines { get; } = []; public List 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) => new PrimeRunner( claude, _db.CreateFactory(), new FakeClock(), NullLogger.Instance, broadcaster); 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 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; } }