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:
mika kuns
2026-08-10 14:19:16 +02:00
parent 6a2a19cc9e
commit ca6eb62b5c
3 changed files with 280 additions and 7 deletions
@@ -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()
{