merge: MCP surface — worktree/diff/merge/log tools + status-enum docs
This commit is contained in:
@@ -5,10 +5,12 @@ using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Tests.Services;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -87,9 +89,17 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
return task;
|
||||
}
|
||||
|
||||
private ExternalMcpService BuildSut(QueueService queue) =>
|
||||
new(_tasks, _lists, queue, _broadcaster,
|
||||
TaskStateServiceBuilder.Build(_db.CreateFactory()).State);
|
||||
private ExternalMcpService BuildSut(QueueService queue)
|
||||
{
|
||||
var git = new GitService();
|
||||
var factory = _db.CreateFactory();
|
||||
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var merge = new TaskMergeService(factory, git, _broadcaster, NullLogger<TaskMergeService>.Instance);
|
||||
return new ExternalMcpService(
|
||||
_tasks, _lists, queue, _broadcaster,
|
||||
TaskStateServiceBuilder.Build(factory).State,
|
||||
git, factory, maintenance, merge);
|
||||
}
|
||||
|
||||
private QueueService CreateQueue()
|
||||
{
|
||||
|
||||
@@ -54,13 +54,16 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_NoLog_Throws()
|
||||
public async Task GetTaskLog_NoRun_ReturnsUnavailable()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.GetTaskLog(taskId, CancellationToken.None));
|
||||
var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Empty(result.Entries);
|
||||
Assert.Equal(0, result.TotalLines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -69,24 +72,26 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
|
||||
await File.WriteAllTextAsync(logPath, "hello log");
|
||||
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,
|
||||
});
|
||||
|
||||
string content;
|
||||
TaskLogResult result;
|
||||
try
|
||||
{
|
||||
content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||
result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(logPath);
|
||||
}
|
||||
|
||||
Assert.Equal("hello log", content);
|
||||
Assert.True(result.Available);
|
||||
Assert.Equal(3, result.TotalLines);
|
||||
Assert.Contains("line1", result.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,7 +102,7 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_RunExistsButNoLogPath_Throws()
|
||||
public async Task GetTaskLog_RunExistsButNoLogPath_ReturnsUnavailable()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
@@ -107,22 +112,22 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
IsRetry = false, Prompt = "p", LogPath = null,
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.GetTaskLog(taskId, CancellationToken.None));
|
||||
var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Empty(result.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_LargeFile_ReturnsTruncatedTail()
|
||||
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 300 KB so it exceeds the 256 KB cap
|
||||
var chunk = new string('A', 1024);
|
||||
await using (var w = new StreamWriter(logPath, append: false))
|
||||
for (var i = 0; i < 300; i++)
|
||||
await w.WriteAsync(chunk);
|
||||
// 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
|
||||
{
|
||||
@@ -130,17 +135,84 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
IsRetry = false, Prompt = "p", LogPath = logPath,
|
||||
});
|
||||
|
||||
string content;
|
||||
TaskLogResult result;
|
||||
try
|
||||
{
|
||||
content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||
result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(logPath);
|
||||
}
|
||||
|
||||
Assert.StartsWith("[truncated:", content);
|
||||
Assert.True(content.Length < 300 * 1024);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user