BUNDLE — all changes live in src/ClaudeDo.Worker/External/ExternalMcpService.cs only, so this is one worktree / one merge. Do NOT touch run-recording or data-layer code (those are separate tasks). Reuse the existing services behind the UI modals (WorktreesOverviewModalView, DiffModalView, MergeModalView) — do not reimplement git plumbing. Build green after each addition. Add these MCP tools: 1. g ClaudeDo-Task: f6bdfb5b-8cbf-4e65-93d4-6c758a160484
219 lines
7.1 KiB
C#
219 lines
7.1 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.External;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
public sealed class RunHistoryMcpToolsTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRunRepository _runs;
|
|
private readonly RunHistoryMcpTools _sut;
|
|
|
|
public RunHistoryMcpToolsTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_runs = new TaskRunRepository(_ctx);
|
|
_sut = new RunHistoryMcpTools(_runs);
|
|
}
|
|
|
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
|
|
|
private async Task SeedTaskAsync(string taskId)
|
|
{
|
|
var lists = new ListRepository(_ctx);
|
|
var tasks = new TaskRepository(_ctx);
|
|
var listId = Guid.NewGuid().ToString();
|
|
await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
await tasks.AddAsync(new TaskEntity
|
|
{
|
|
Id = taskId, ListId = listId, Title = "t",
|
|
Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListRuns_ReturnsProjectedRuns()
|
|
{
|
|
var taskId = Guid.NewGuid().ToString();
|
|
await SeedTaskAsync(taskId);
|
|
await _runs.AddAsync(new TaskRunEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
|
IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20,
|
|
});
|
|
|
|
var list = await _sut.ListRuns(taskId, CancellationToken.None);
|
|
|
|
Assert.Single(list);
|
|
Assert.Equal("done", list[0].ResultMarkdown);
|
|
Assert.Equal(10, list[0].TokensIn);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskLog_NoRun_ReturnsUnavailable()
|
|
{
|
|
var taskId = Guid.NewGuid().ToString();
|
|
await SeedTaskAsync(taskId);
|
|
|
|
var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Available);
|
|
Assert.Empty(result.Entries);
|
|
Assert.Equal(0, result.TotalLines);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskLog_ReadsLatestRunLogFile()
|
|
{
|
|
var taskId = Guid.NewGuid().ToString();
|
|
await SeedTaskAsync(taskId);
|
|
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
|
|
await File.WriteAllTextAsync(logPath, "line1\nline2\nline3");
|
|
await _runs.AddAsync(new TaskRunEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
|
IsRetry = false, Prompt = "p", LogPath = logPath,
|
|
});
|
|
|
|
TaskLogResult result;
|
|
try
|
|
{
|
|
result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(logPath);
|
|
}
|
|
|
|
Assert.True(result.Available);
|
|
Assert.Equal(3, result.TotalLines);
|
|
Assert.Contains("line1", result.Entries);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRun_NotFound_Throws()
|
|
{
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
_sut.GetRun("missing", CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskLog_RunExistsButNoLogPath_ReturnsUnavailable()
|
|
{
|
|
var taskId = Guid.NewGuid().ToString();
|
|
await SeedTaskAsync(taskId);
|
|
await _runs.AddAsync(new TaskRunEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
|
IsRetry = false, Prompt = "p", LogPath = null,
|
|
});
|
|
|
|
var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.False(result.Available);
|
|
Assert.Empty(result.Entries);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskLog_ManyLines_DefaultTailReturnsLast50()
|
|
{
|
|
var taskId = Guid.NewGuid().ToString();
|
|
await SeedTaskAsync(taskId);
|
|
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
|
|
|
|
// Write 108 lines (the observed real-world size that exceeded token limits)
|
|
var lines = Enumerable.Range(1, 108).Select(i => $"{{\"line\":{i}}}");
|
|
await File.WriteAllLinesAsync(logPath, lines);
|
|
|
|
await _runs.AddAsync(new TaskRunEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
|
IsRetry = false, Prompt = "p", LogPath = logPath,
|
|
});
|
|
|
|
TaskLogResult result;
|
|
try
|
|
{
|
|
result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(logPath);
|
|
}
|
|
|
|
Assert.True(result.Available);
|
|
Assert.True(result.Truncated);
|
|
Assert.Equal(108, result.TotalLines);
|
|
Assert.Equal(50, result.Entries.Count);
|
|
Assert.Contains("{\"line\":108}", result.Entries); // last line is present
|
|
Assert.DoesNotContain("{\"line\":1}", result.Entries); // first line is not
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskLog_TailParam_ReturnsRequestedCount()
|
|
{
|
|
var taskId = Guid.NewGuid().ToString();
|
|
await SeedTaskAsync(taskId);
|
|
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
|
|
var lines = Enumerable.Range(1, 20).Select(i => $"line{i}");
|
|
await File.WriteAllLinesAsync(logPath, lines);
|
|
|
|
await _runs.AddAsync(new TaskRunEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
|
IsRetry = false, Prompt = "p", LogPath = logPath,
|
|
});
|
|
|
|
TaskLogResult result;
|
|
try
|
|
{
|
|
result = await _sut.GetTaskLog(taskId, tail: 5, cancellationToken: CancellationToken.None);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(logPath);
|
|
}
|
|
|
|
Assert.True(result.Available);
|
|
Assert.True(result.Truncated);
|
|
Assert.Equal(5, result.Entries.Count);
|
|
Assert.Equal("line20", result.Entries[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetTaskLog_OffsetLimit_ReturnsSlice()
|
|
{
|
|
var taskId = Guid.NewGuid().ToString();
|
|
await SeedTaskAsync(taskId);
|
|
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
|
|
var lines = Enumerable.Range(1, 10).Select(i => $"line{i}");
|
|
await File.WriteAllLinesAsync(logPath, lines);
|
|
|
|
await _runs.AddAsync(new TaskRunEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
|
IsRetry = false, Prompt = "p", LogPath = logPath,
|
|
});
|
|
|
|
TaskLogResult result;
|
|
try
|
|
{
|
|
result = await _sut.GetTaskLog(taskId, offset: 2, limit: 3, cancellationToken: CancellationToken.None);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(logPath);
|
|
}
|
|
|
|
Assert.True(result.Available);
|
|
Assert.Equal(3, result.Entries.Count);
|
|
Assert.Equal("line3", result.Entries[0]);
|
|
Assert.Equal("line5", result.Entries[^1]);
|
|
Assert.True(result.Truncated);
|
|
}
|
|
}
|