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(() => _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); } }