feat(prime): ImportTickets runs the ticket import before filling MyDay
This commit is contained in:
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Prime;
|
namespace ClaudeDo.Worker.Prime;
|
||||||
@@ -16,6 +17,7 @@ public sealed class PrimeRunner : IPrimeRunner
|
|||||||
private readonly IPrimeClock _clock;
|
private readonly IPrimeClock _clock;
|
||||||
private readonly ILogger<PrimeRunner> _logger;
|
private readonly ILogger<PrimeRunner> _logger;
|
||||||
private readonly IPrimeBroadcaster _broadcaster;
|
private readonly IPrimeBroadcaster _broadcaster;
|
||||||
|
private readonly TicketSystemConfig _tickets;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
public PrimeRunner(
|
public PrimeRunner(
|
||||||
@@ -23,13 +25,15 @@ public sealed class PrimeRunner : IPrimeRunner
|
|||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
IPrimeClock clock,
|
IPrimeClock clock,
|
||||||
ILogger<PrimeRunner> logger,
|
ILogger<PrimeRunner> logger,
|
||||||
IPrimeBroadcaster broadcaster)
|
IPrimeBroadcaster broadcaster,
|
||||||
|
TicketSystemConfig tickets)
|
||||||
{
|
{
|
||||||
_claude = claude;
|
_claude = claude;
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_clock = clock;
|
_clock = clock;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
|
_tickets = tickets;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
|
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
|
||||||
@@ -42,7 +46,8 @@ public sealed class PrimeRunner : IPrimeRunner
|
|||||||
{
|
{
|
||||||
return schedule.Kind switch
|
return schedule.Kind switch
|
||||||
{
|
{
|
||||||
PrimeActionKind.FillMyDay => await RunFillMyDayAsync(schedule, ct),
|
PrimeActionKind.FillMyDay => await RunPrepAsync("Daily prep", log => FillMyDayStepAsync(schedule, log, ct)),
|
||||||
|
PrimeActionKind.ImportTickets => await RunImportTicketsAsync(schedule, ct),
|
||||||
PrimeActionKind.Custom => await RunCustomAsync(schedule, ct),
|
PrimeActionKind.Custom => await RunCustomAsync(schedule, ct),
|
||||||
_ => await RunPingAsync(ct),
|
_ => await RunPingAsync(ct),
|
||||||
};
|
};
|
||||||
@@ -53,7 +58,37 @@ public sealed class PrimeRunner : IPrimeRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<PrimeRunOutcome> RunFillMyDayAsync(PrimeScheduleDto schedule, CancellationToken ct)
|
/// <summary>Ticket import, then FillMyDay — one log, one PrepStarted/PrepFinished pair. A failed import never
|
||||||
|
/// blocks the day plan (the existing tasks are still worth planning); without a ticket system it is plain FillMyDay.</summary>
|
||||||
|
private Task<PrimeRunOutcome> RunImportTicketsAsync(PrimeScheduleDto schedule, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_tickets.IsConfigured)
|
||||||
|
return RunPrepAsync("Daily prep", log => FillMyDayStepAsync(schedule, log, ct));
|
||||||
|
|
||||||
|
return RunPrepAsync("Ticket import", async log =>
|
||||||
|
{
|
||||||
|
PrimeRunOutcome import;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
import = await RunLoggedAsync(
|
||||||
|
TicketImportPrompt.BuildArgs(), TicketImportPrompt.BuildPrompt(schedule.PromptOverride),
|
||||||
|
log, "Tickets imported", ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Ticket import step failed");
|
||||||
|
import = new PrimeRunOutcome(false, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var day = await FillMyDayStepAsync(schedule, log, ct);
|
||||||
|
return import.Success
|
||||||
|
? day with { Message = $"Tickets imported; {day.Message}" }
|
||||||
|
: new PrimeRunOutcome(false, $"Ticket import failed ({import.Message}); {day.Message}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Truncates the prep log once and brackets <paramref name="body"/> with PrepStarted/PrepFinished.</summary>
|
||||||
|
private async Task<PrimeRunOutcome> RunPrepAsync(string label, Func<LogWriter, Task<PrimeRunOutcome>> body)
|
||||||
{
|
{
|
||||||
var success = false;
|
var success = false;
|
||||||
try
|
try
|
||||||
@@ -64,44 +99,13 @@ public sealed class PrimeRunner : IPrimeRunner
|
|||||||
|
|
||||||
await _broadcaster.PrepStartedAsync();
|
await _broadcaster.PrepStartedAsync();
|
||||||
|
|
||||||
var cwd = Cwd();
|
var outcome = await body(logWriter);
|
||||||
|
success = outcome.Success;
|
||||||
int maxTasks;
|
return outcome;
|
||||||
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
|
|
||||||
{
|
|
||||||
var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct);
|
|
||||||
maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
|
||||||
}
|
|
||||||
|
|
||||||
var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
|
|
||||||
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today, schedule.PromptOverride);
|
|
||||||
|
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeoutCts.CancelAfter(FireTimeout);
|
|
||||||
|
|
||||||
var result = await _claude.RunAsync(
|
|
||||||
arguments: DailyPrepPrompt.BuildArgs(MaxTurns),
|
|
||||||
prompt: prompt,
|
|
||||||
workingDirectory: cwd,
|
|
||||||
onStdoutLine: async line =>
|
|
||||||
{
|
|
||||||
await logWriter.WriteLineAsync(line);
|
|
||||||
await _broadcaster.PrepLineAsync(line);
|
|
||||||
},
|
|
||||||
ct: timeoutCts.Token);
|
|
||||||
|
|
||||||
success = result.IsSuccess;
|
|
||||||
return success
|
|
||||||
? new PrimeRunOutcome(true, "Daily prep complete")
|
|
||||||
: new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Daily prep run failed");
|
_logger.LogWarning(ex, "{Label} run failed", label);
|
||||||
return new PrimeRunOutcome(false, ex.Message);
|
return new PrimeRunOutcome(false, ex.Message);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -110,6 +114,53 @@ public sealed class PrimeRunner : IPrimeRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The MyDay selection run, writing into an already-open prep log.</summary>
|
||||||
|
private async Task<PrimeRunOutcome> FillMyDayStepAsync(PrimeScheduleDto schedule, LogWriter log, CancellationToken ct)
|
||||||
|
{
|
||||||
|
int maxTasks;
|
||||||
|
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
|
{
|
||||||
|
var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct);
|
||||||
|
maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
|
||||||
|
return await RunLoggedAsync(
|
||||||
|
DailyPrepPrompt.BuildArgs(MaxTurns),
|
||||||
|
DailyPrepPrompt.BuildPrompt(maxTasks, today, schedule.PromptOverride),
|
||||||
|
log, "Daily prep complete", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One claude run streaming into the prep log and the UI; a timeout is an outcome, not an exception.</summary>
|
||||||
|
private async Task<PrimeRunOutcome> RunLoggedAsync(
|
||||||
|
IReadOnlyList<string> args, string prompt, LogWriter log, string successMessage, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeoutCts.CancelAfter(FireTimeout);
|
||||||
|
|
||||||
|
var result = await _claude.RunAsync(
|
||||||
|
arguments: args,
|
||||||
|
prompt: prompt,
|
||||||
|
workingDirectory: Cwd(),
|
||||||
|
onStdoutLine: async line =>
|
||||||
|
{
|
||||||
|
await log.WriteLineAsync(line);
|
||||||
|
await _broadcaster.PrepLineAsync(line);
|
||||||
|
},
|
||||||
|
ct: timeoutCts.Token);
|
||||||
|
|
||||||
|
return result.IsSuccess
|
||||||
|
? new PrimeRunOutcome(true, successMessage)
|
||||||
|
: new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Task<PrimeRunOutcome> RunPingAsync(CancellationToken ct) =>
|
private Task<PrimeRunOutcome> RunPingAsync(CancellationToken ct) =>
|
||||||
RunQuietAsync(PrimePrompts.BuildPingArgs(), PrimePrompts.PingPrompt, "Ping complete", "Ping", ct);
|
RunQuietAsync(PrimePrompts.BuildPingArgs(), PrimePrompts.PingPrompt, "Ping complete", "Ping", ct);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Prime;
|
||||||
|
|
||||||
|
/// <summary>Prompt and CLI args for the ticket-import step of an ImportTickets Prime run.</summary>
|
||||||
|
public static class TicketImportPrompt
|
||||||
|
{
|
||||||
|
public const string CandidatesTool = "mcp__claudedo__get_ticket_import_candidates";
|
||||||
|
public const string ImportTool = "mcp__claudedo__import_ticket";
|
||||||
|
|
||||||
|
/// <summary>One import call per item, so the budget scales with the backlog — daily prep's 30 would cap it at ~25 items.</summary>
|
||||||
|
public const int MaxTurns = 60;
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> BuildArgs() =>
|
||||||
|
[
|
||||||
|
"-p", "--output-format", "stream-json", "--verbose",
|
||||||
|
"--permission-mode", "acceptEdits",
|
||||||
|
"--max-turns", MaxTurns.ToString(),
|
||||||
|
"--allowedTools", CandidatesTool, ImportTool,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>A schedule's override is appended, never substituted — same rule as <see cref="DailyPrepPrompt"/>.</summary>
|
||||||
|
public static string BuildPrompt(string? promptOverride = null)
|
||||||
|
{
|
||||||
|
var prompt = PromptFiles.Render(PromptKind.TicketImport, new Dictionary<string, string>());
|
||||||
|
return string.IsNullOrWhiteSpace(promptOverride) ? prompt : $"{prompt}\n\n{promptOverride}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Worker;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Prime;
|
using ClaudeDo.Worker.Prime;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Tests.Prime;
|
namespace ClaudeDo.Worker.Tests.Prime;
|
||||||
@@ -9,7 +12,25 @@ namespace ClaudeDo.Worker.Tests.Prime;
|
|||||||
public class PrimeRunnerTests : IDisposable
|
public class PrimeRunnerTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly DbFixture _db = new();
|
private readonly DbFixture _db = new();
|
||||||
public void Dispose() => _db.Dispose();
|
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
|
private sealed class FakeClock : IPrimeClock
|
||||||
{
|
{
|
||||||
@@ -22,16 +43,20 @@ public class PrimeRunnerTests : IDisposable
|
|||||||
private readonly int _exitCode;
|
private readonly int _exitCode;
|
||||||
private readonly string[] _emitLines;
|
private readonly string[] _emitLines;
|
||||||
private readonly string? _result;
|
private readonly string? _result;
|
||||||
|
private readonly Func<IReadOnlyList<string>, int>? _exitCodeFor;
|
||||||
|
|
||||||
public IReadOnlyList<string>? LastArguments { get; private set; }
|
public IReadOnlyList<string>? LastArguments { get; private set; }
|
||||||
public string? LastPrompt { 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)
|
public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0, string[]? emitLines = null, string? result = null,
|
||||||
|
Func<IReadOnlyList<string>, int>? exitCodeFor = null)
|
||||||
{
|
{
|
||||||
_delay = delay;
|
_delay = delay;
|
||||||
_exitCode = exitCode;
|
_exitCode = exitCode;
|
||||||
_emitLines = emitLines ?? [];
|
_emitLines = emitLines ?? [];
|
||||||
_result = result;
|
_result = result;
|
||||||
|
_exitCodeFor = exitCodeFor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RunResult> RunAsync(
|
public async Task<RunResult> RunAsync(
|
||||||
@@ -41,6 +66,7 @@ public class PrimeRunnerTests : IDisposable
|
|||||||
Func<string, Task> onStdoutLine,
|
Func<string, Task> onStdoutLine,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
AllArguments.Add(arguments);
|
||||||
LastArguments = arguments;
|
LastArguments = arguments;
|
||||||
LastPrompt = prompt;
|
LastPrompt = prompt;
|
||||||
|
|
||||||
@@ -50,7 +76,8 @@ public class PrimeRunnerTests : IDisposable
|
|||||||
foreach (var line in _emitLines)
|
foreach (var line in _emitLines)
|
||||||
await onStdoutLine(line);
|
await onStdoutLine(line);
|
||||||
|
|
||||||
return new RunResult { ExitCode = _exitCode, ResultMarkdown = _result ?? (_exitCode == 0 ? "ok" : null) };
|
var exitCode = _exitCodeFor?.Invoke(arguments) ?? _exitCode;
|
||||||
|
return new RunResult { ExitCode = exitCode, ResultMarkdown = _result ?? (exitCode == 0 ? "ok" : null) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +97,14 @@ public class PrimeRunnerTests : IDisposable
|
|||||||
private PrimeRunner NewRunner(TimeSpan claudeDelay = default, int exitCode = 0) =>
|
private PrimeRunner NewRunner(TimeSpan claudeDelay = default, int exitCode = 0) =>
|
||||||
NewRunner(new FakeClaudeProcess(claudeDelay, exitCode), new RecordingPrimeBroadcaster());
|
NewRunner(new FakeClaudeProcess(claudeDelay, exitCode), new RecordingPrimeBroadcaster());
|
||||||
|
|
||||||
private PrimeRunner NewRunner(FakeClaudeProcess claude, IPrimeBroadcaster broadcaster) =>
|
private PrimeRunner NewRunner(FakeClaudeProcess claude, IPrimeBroadcaster broadcaster, bool ticketsConfigured = false) =>
|
||||||
new PrimeRunner(
|
new PrimeRunner(
|
||||||
claude,
|
claude,
|
||||||
_db.CreateFactory(),
|
_db.CreateFactory(),
|
||||||
new FakeClock(),
|
new FakeClock(),
|
||||||
NullLogger<PrimeRunner>.Instance,
|
NullLogger<PrimeRunner>.Instance,
|
||||||
broadcaster);
|
broadcaster,
|
||||||
|
Tickets(ticketsConfigured));
|
||||||
|
|
||||||
private static PrimeScheduleDto DefaultSchedule() =>
|
private static PrimeScheduleDto DefaultSchedule() =>
|
||||||
new(Guid.Empty, 0, TimeSpan.Zero, true, null, null, PrimeActionKind.FillMyDay);
|
new(Guid.Empty, 0, TimeSpan.Zero, true, null, null, PrimeActionKind.FillMyDay);
|
||||||
@@ -241,6 +269,66 @@ public class PrimeRunnerTests : IDisposable
|
|||||||
Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.LastArguments!);
|
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]
|
[Fact]
|
||||||
public async Task Gate_is_shared_across_kinds()
|
public async Task Gate_is_shared_across_kinds()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using ClaudeDo.Worker.Prime;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Prime;
|
||||||
|
|
||||||
|
public class TicketImportPromptTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Args_allow_only_the_two_ticket_tools()
|
||||||
|
{
|
||||||
|
var args = TicketImportPrompt.BuildArgs();
|
||||||
|
var i = args.ToList().IndexOf("--allowedTools");
|
||||||
|
|
||||||
|
Assert.Equal([TicketImportPrompt.CandidatesTool, TicketImportPrompt.ImportTool], args.Skip(i + 1));
|
||||||
|
Assert.Contains("acceptEdits", args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Override_is_appended_never_substituted()
|
||||||
|
{
|
||||||
|
var baseline = TicketImportPrompt.BuildPrompt();
|
||||||
|
var prompt = TicketImportPrompt.BuildPrompt("Lager items go to Hub.");
|
||||||
|
|
||||||
|
Assert.StartsWith(baseline, prompt);
|
||||||
|
Assert.EndsWith("Lager items go to Hub.", prompt);
|
||||||
|
Assert.Equal(baseline, TicketImportPrompt.BuildPrompt(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user