feat(mcp): truncate/select batch_get_tasks payload, expose roadblockText
batch_get_tasks with includeDescription=true had no size guard: 8 tasks' full Description/Result serialized to a single 51k-char line, blowing past the tool-result token budget and forcing a file/PowerShell workaround to read it back. Roadblock text was also only reachable via a second get_task call per task, since roadblockCount (in the lean ref) has no text sibling. - descriptionMaxChars (default 1500, was unlimited) truncates Description and Result independently, flagged via *Truncated/*FullLength so a caller never silently works off a cut string. - fields narrows taskFull to just the named properties, including the new roadblockText (the tail of Result after TaskRunner's roadblock marker) — reachable without pulling in the rest of Result/Description. - taskFull is now BatchTaskDetailDto, a batch-only shape decoupled from TaskDto so get_task's own contract is untouched. - The whole response is capped (MaxResponseChars); over that, the call throws naming which parameter (descriptionMaxChars/fields/taskIds) to adjust instead of shipping an oversized payload. 10 tasks with 5000-char descriptions (default settings) serialize to ~13.8k chars, comfortably under the 25k-char cap.
This commit is contained in:
@@ -216,6 +216,156 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
Assert.Equal("the full description", found.TaskFull!.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchGetTasks_DescriptionLongerThanCap_IsTruncated_WithFlagAndFullLength()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
var full = new string('x', 50);
|
||||
task.Description = full;
|
||||
await _tasks.UpdateAsync(task);
|
||||
var sut = BuildSut();
|
||||
|
||||
var results = await sut.BatchGetTasks(
|
||||
new[] { task.Id }, includeDescription: true, descriptionMaxChars: 10, cancellationToken: CancellationToken.None);
|
||||
|
||||
var found = results.Single(r => r.Id == task.Id).TaskFull!;
|
||||
Assert.Equal(new string('x', 10), found.Description);
|
||||
Assert.True(found.DescriptionTruncated);
|
||||
Assert.Equal(50, found.DescriptionFullLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchGetTasks_DescriptionUnderCap_IsNotTruncated()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
task.Description = "short";
|
||||
await _tasks.UpdateAsync(task);
|
||||
var sut = BuildSut();
|
||||
|
||||
var results = await sut.BatchGetTasks(
|
||||
new[] { task.Id }, includeDescription: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
var found = results.Single(r => r.Id == task.Id).TaskFull!;
|
||||
Assert.Equal("short", found.Description);
|
||||
Assert.False(found.DescriptionTruncated);
|
||||
Assert.Null(found.DescriptionFullLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchGetTasks_FieldsRestrictsPayload_ToRequestedFieldsOnly()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
task.Description = "a description that should not come back when fields excludes it";
|
||||
await _tasks.UpdateAsync(task);
|
||||
var sut = BuildSut();
|
||||
|
||||
var results = await sut.BatchGetTasks(
|
||||
new[] { task.Id }, includeDescription: true, fields: new[] { "title", "status" },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
var found = results.Single(r => r.Id == task.Id).TaskFull!;
|
||||
Assert.Equal(task.Title, found.Title);
|
||||
Assert.NotNull(found.Status);
|
||||
Assert.Null(found.Description);
|
||||
Assert.Null(found.ListId);
|
||||
Assert.Null(found.RoadblockCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchGetTasks_UnknownField_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
var sut = BuildSut();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.BatchGetTasks(
|
||||
new[] { task.Id }, includeDescription: true, fields: new[] { "notAField" },
|
||||
cancellationToken: CancellationToken.None));
|
||||
Assert.Contains("notAField", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchGetTasks_RoadblockTextField_ExtractsTailOfResult_WithoutFullResult()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
task.Result = "the task's own result\n\n⚠ Roadblocks reported during the run:\n- missing credentials";
|
||||
await _tasks.UpdateAsync(task);
|
||||
var sut = BuildSut();
|
||||
|
||||
var results = await sut.BatchGetTasks(
|
||||
new[] { task.Id }, includeDescription: true, fields: new[] { "roadblockText" },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
var found = results.Single(r => r.Id == task.Id).TaskFull!;
|
||||
Assert.Equal("- missing credentials", found.RoadblockText);
|
||||
Assert.Null(found.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchGetTasks_NoRoadblockMarker_RoadblockTextIsNull()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
task.Result = "plain result, no roadblocks";
|
||||
await _tasks.UpdateAsync(task);
|
||||
var sut = BuildSut();
|
||||
|
||||
var results = await sut.BatchGetTasks(
|
||||
new[] { task.Id }, includeDescription: true, fields: new[] { "roadblockText" },
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Null(results.Single(r => r.Id == task.Id).TaskFull!.RoadblockText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchGetTasks_OversizedResponse_ThrowsNamingTheFixingParameters()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var ids = new List<string>();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var task = await SeedTaskAsync(listId, $"t{i}");
|
||||
task.Description = new string('d', 6000);
|
||||
await _tasks.UpdateAsync(task);
|
||||
ids.Add(task.Id);
|
||||
}
|
||||
var sut = BuildSut();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.BatchGetTasks(
|
||||
ids.ToArray(), includeDescription: true, descriptionMaxChars: 6000, cancellationToken: CancellationToken.None));
|
||||
Assert.Contains("descriptionMaxChars", ex.Message);
|
||||
Assert.Contains("fields", ex.Message);
|
||||
Assert.Contains("taskIds", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchGetTasks_TenTasksWithDescriptions_StaysWithinTokenBudget()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var ids = new List<string>();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var task = await SeedTaskAsync(listId, $"t{i}");
|
||||
task.Description = new string('d', 5000);
|
||||
await _tasks.UpdateAsync(task);
|
||||
ids.Add(task.Id);
|
||||
}
|
||||
var sut = BuildSut();
|
||||
|
||||
var results = await sut.BatchGetTasks(ids.ToArray(), includeDescription: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(10, results.Count);
|
||||
Assert.All(results, r => Assert.True(r.TaskFull!.DescriptionTruncated));
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(results);
|
||||
Assert.True(json.Length < 25_000, $"response was {json.Length} chars, expected < 25000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user