Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Darling/Darling.Tests/DarlingComposeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,17 @@ public void Catalog_CarriesTheComposeSection()
Assert.NotNull(compose["aggregates"]);
Assert.NotNull(compose["timeBuckets"]);
Assert.NotNull(compose["filterOps"]);

/* #4198: describe_custom_view_catalog's MCP default is now COMPACT (measures grouped by source, most
fields dropped) to fit the tool's 32 KB response budget, but /api/catalog routes here — straight to
BuildComposeCatalogNode(), never through the MCP tool method or its new compact/source-filter logic —
so the web Custom Views editor must still see every measure at full per-field detail, unfiltered and
ungrouped. A regression that wired /api/catalog through the compact builder would drop these fields. */
var firstMeasure = Assert.IsType<JsonObject>(measures[0]);
Assert.NotNull(firstMeasure["appliesTo"]);
Assert.NotNull(firstMeasure["allowedDimensions"]);
Assert.NotNull(firstMeasure["category"]);
Assert.Null(compose["compact"]);
}

/* ─────────────────────────── DoS backstop + loopback scrub (provisioning) ─────────────────────────── */
Expand Down
89 changes: 89 additions & 0 deletions Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

// Size tests for describe_custom_view_catalog's compact and full-detail modes.
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Service.Mcp;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #4198 field-size coverage for <c>describe_custom_view_catalog</c>. This tool needs no rig: it is STATIC
/// reference data straight off <c>MeasureCatalog</c> (no server, time window, or store read), so its byte size
/// does not depend on seeded rows the way a row-shaped read tool's does — it depends on the catalog's own field
/// count, which is why it is NOT on the generic <c>McpReadToolBudgetLiveTests</c> roster (#4224, not yet merged;
/// that tool has no server/store argument to seed a payload from) and instead gets this dedicated file.
///
/// <para><b>Before #4198:</b> the full catalog measured 98,173 UTF-8 bytes at default arguments — matching the
/// #4198 issue's own measurement exactly — three times the tool's 32 KB <see cref="McpResponseBudget.DefaultBytes"/>
/// budget. <b>After:</b> the default call groups measures by source and keeps only key/displayName/kind/
/// unitFamily/validAggregates per measure (measured below, well under budget); <c>source=&lt;name&gt;</c> and
/// <c>full_detail=true</c> reach everything the compact default leaves out, and <c>full_detail=true</c> alone
/// reproduces the original 98,173-byte shape byte-for-byte.</para>
/// </summary>
public sealed class DarlingMcpCustomViewCatalogSizeTests
{
/// <summary>The #4198 issue's own measurement of this tool's pre-fix default-argument size (SQL Server store
/// AND PostgreSQL-target store both measured 98,173 B) — this catalog is static, so both stores read the
/// same code path and land on the same number. Pinned so a future catalog edit that silently balloons
/// full_detail's shape is caught here, not by a caller getting refused inline again.</summary>
private const int PreFixFullDetailBytes = 98_173;

[Fact]
public async Task FullDetail_MatchesThePreFixMeasurement()
{
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(full_detail: true);
var bytes = Encoding.UTF8.GetByteCount(result);

/* Exact match, not a ceiling: full_detail is the #4198 escape hatch and must keep serving EXACTLY what
this tool always returned, so a drift here is either a real catalog change (update the constant, with
the reason) or a regression in the compaction logic leaking into the full-detail path. */
Assert.Equal(PreFixFullDetailBytes, bytes);
}

[Fact]
public async Task Default_IsUnderBudget()
{
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog();
var bytes = Encoding.UTF8.GetByteCount(result);

Assert.True(bytes < McpResponseBudget.DefaultBytes,
$"describe_custom_view_catalog's default call is {bytes:N0} bytes, over the {McpResponseBudget.DefaultBytes:N0}-byte budget (#4198).");

/* Leave real margin, not just a pass: measured at 30,215 B against a 32,768 B budget (about 2.5 KB /
8% headroom) when this was written, so a compaction that only scraped under the ceiling would be one
new measure away from blowing it again. 2,000 bytes is comfortably inside that measured margin. */
const int MinimumHeadroomBytes = 2_000;
Assert.True(bytes < McpResponseBudget.DefaultBytes - MinimumHeadroomBytes,
$"describe_custom_view_catalog's default call is {bytes:N0} bytes - within {MinimumHeadroomBytes:N0} bytes of the {McpResponseBudget.DefaultBytes:N0}-byte budget, with too little headroom for the catalog to grow.");
}

[Fact]
public async Task SourceDrillDown_StaysUnderBudget_ForTheWidestSource()
{
/* The widest source by measure count is the worst case for a single source= call; even it must clear
the budget on its own (full per-measure detail, not compact). */
var compact = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog();
using var compactDoc = JsonDocument.Parse(compact);
var widestSource = compactDoc.RootElement.GetProperty("sources").EnumerateArray()
.OrderByDescending(s => s.GetProperty("measures").GetArrayLength())
.First()
.GetProperty("source").GetString()!;

var drill = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(source: widestSource);
var bytes = Encoding.UTF8.GetByteCount(drill);

Assert.True(bytes < McpResponseBudget.DefaultBytes,
$"describe_custom_view_catalog(source: '{widestSource}') is {bytes:N0} bytes, over the {McpResponseBudget.DefaultBytes:N0}-byte budget (#4198).");
}
}
112 changes: 105 additions & 7 deletions Darling/Darling.Tests/DarlingMcpCustomViewToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,28 +141,126 @@ public async Task ValidateCustomView_BadMeasure_ReturnsValidFalse_WithError()
}

[Fact]
public async Task DescribeCustomViewCatalog_ReturnsTheComposeVocabulary()
public async Task DescribeCustomViewCatalog_DefaultIsCompact_GroupedBySource()
{
/* The catalog tool exists so an LLM composes a valid panel WITHOUT reading source or guessing names — it
must surface every vocabulary a panel draws from, plus a known measure with its composable fields. */
must surface every vocabulary a panel draws from, plus a known measure with its composable fields.
#4198: the full catalog is 98 KB, so the DEFAULT call groups measures by source and keeps only the
fields a panel spec actually names (key/displayName/kind/unitFamily/validAggregates); source=/
full_detail= reach the rest. Byte-budget coverage of this default lives in
DarlingMcpCustomViewCatalogSizeTests (#4198 exempt from McpReadToolBudgetLiveTests — no server/store
argument to seed). */
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog();
using var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

foreach (var section in new[] { "measures", "dimensions", "unitFamilies", "aggregates", "timeBuckets", "filterOps", "viz" })
foreach (var section in new[] { "sources", "annotationSources", "unitFamilies", "aggregates", "timeBuckets", "filterOps", "viz" })
{
Assert.True(root.TryGetProperty(section, out _), $"catalog is missing the '{section}' vocabulary");
Assert.True(root.TryGetProperty(section, out _), $"compact catalog is missing the '{section}' vocabulary");
}

/* A known measure is discoverable with the fields a panel binds from it (source + valid aggregates). */
Assert.True(root.GetProperty("compact").GetBoolean());

/* A known measure is discoverable, grouped under its source, with the fields a panel binds from it. */
var waitStats = root.GetProperty("sources").EnumerateArray().Single(s => s.GetProperty("source").GetString() == "wait_stats");
var waitTime = waitStats.GetProperty("measures").EnumerateArray().Single(m => m.GetProperty("key").GetString() == "wait_time_ms");
Assert.Equal("scalar", waitTime.GetProperty("kind").GetString());
Assert.Contains("sum", waitTime.GetProperty("validAggregates").EnumerateArray().Select(a => a.GetString()));

/* The scalar vocabularies the panel's aggregate + viz fields draw from. */
Assert.Contains("sum", root.GetProperty("aggregates").EnumerateArray().Select(a => a.GetString()));
Assert.Contains("bar", root.GetProperty("viz").EnumerateArray().Select(v => v.GetString()));
}

[Fact]
public async Task DescribeCustomViewCatalog_FullDetail_ReturnsTodaysOriginalShape()
{
/* full_detail=true is the #4198 escape hatch: the exact flat shape (and every field) this tool always
returned, byte-for-byte what BuildComposeCatalogNode / the web /api/catalog compose section serve. */
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(full_detail: true);
using var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

foreach (var section in new[] { "measures", "dimensions", "annotationSources", "universalDimensions", "unitFamilies", "aggregates", "timeBuckets", "filterOps", "viz" })
{
Assert.True(root.TryGetProperty(section, out _), $"full_detail catalog is missing the '{section}' vocabulary");
}

Assert.False(root.TryGetProperty("compact", out _), "full_detail must not carry the compact-mode marker");

var waitTime = root.GetProperty("measures").EnumerateArray()
.Single(m => m.GetProperty("key").GetString() == "wait_time_ms");
Assert.Equal("wait_stats", waitTime.GetProperty("source").GetString());
Assert.Contains("sum", waitTime.GetProperty("validAggregates").EnumerateArray().Select(a => a.GetString()));
Assert.True(waitTime.TryGetProperty("appliesTo", out _), "full_detail must keep appliesTo per measure");
Assert.True(waitTime.TryGetProperty("allowedDimensions", out _), "full_detail must keep allowedDimensions per measure");
}

/* The scalar vocabularies the panel's aggregate + viz fields draw from. */
[Fact]
public async Task DescribeCustomViewCatalog_Source_DrillsIntoOneSource_FullDetail()
{
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(source: "wait_stats");
using var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

Assert.Equal("wait_stats", root.GetProperty("source").GetString());
var measures = root.GetProperty("measures").EnumerateArray().ToList();
Assert.NotEmpty(measures);
Assert.All(measures, m => Assert.Equal("wait_stats", m.GetProperty("source").GetString()));

var waitTime = measures.Single(m => m.GetProperty("key").GetString() == "wait_time_ms");
Assert.True(waitTime.TryGetProperty("appliesTo", out _), "source drill-down must keep appliesTo per measure");
Assert.True(waitTime.TryGetProperty("allowedDimensions", out _), "source drill-down must keep allowedDimensions per measure");

var dimensions = root.GetProperty("dimensions").EnumerateArray().ToList();
Assert.NotEmpty(dimensions);
Assert.All(dimensions, d => Assert.Equal("wait_stats", d.GetProperty("source").GetString()));

/* The small shared vocabularies still ride along, same as every other mode. */
Assert.Contains("sum", root.GetProperty("aggregates").EnumerateArray().Select(a => a.GetString()));
Assert.Contains("bar", root.GetProperty("viz").EnumerateArray().Select(v => v.GetString()));
}

[Fact]
public async Task DescribeCustomViewCatalog_UnknownSource_ReturnsEmptyWithNote_NotAnError()
{
var result = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(source: "no_such_source");
using var doc = JsonDocument.Parse(result);
var root = doc.RootElement;

Assert.Empty(root.GetProperty("measures").EnumerateArray());
Assert.Empty(root.GetProperty("dimensions").EnumerateArray());
Assert.True(root.TryGetProperty("note", out var note), "an unmatched source should explain how to find the real names");
Assert.Contains("no_such_source", note.GetString());
}

[Fact]
public async Task DescribeCustomViewCatalog_CompactDefault_IsLosslessAndUnderBudget()
{
/* Every measure key the compact default advertises must be reachable, at full detail, either by drilling
into its source or by full_detail=true — the #4198 rule that a cut must never hide what an author
needs. Every source name the compact default lists must itself be a working source= filter. */
var compactResult = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog();
Assert.True(System.Text.Encoding.UTF8.GetByteCount(compactResult) < McpResponseBudget.DefaultBytes,
$"default describe_custom_view_catalog call is {System.Text.Encoding.UTF8.GetByteCount(compactResult):N0} bytes, over the {McpResponseBudget.DefaultBytes:N0}-byte budget");

using var compactDoc = JsonDocument.Parse(compactResult);
var compactKeys = compactDoc.RootElement.GetProperty("sources").EnumerateArray()
.SelectMany(s => s.GetProperty("measures").EnumerateArray().Select(m => m.GetProperty("key").GetString()!))
.ToHashSet();

var fullResult = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(full_detail: true);
using var fullDoc = JsonDocument.Parse(fullResult);
var fullKeys = fullDoc.RootElement.GetProperty("measures").EnumerateArray().Select(m => m.GetProperty("key").GetString()!).ToHashSet();

Assert.Equal(fullKeys, compactKeys);

foreach (var sourceElement in compactDoc.RootElement.GetProperty("sources").EnumerateArray())
{
var sourceName = sourceElement.GetProperty("source").GetString()!;
var drillResult = await DarlingMcpCustomViewTools.DescribeCustomViewCatalog(source: sourceName);
using var drillDoc = JsonDocument.Parse(drillResult);
Assert.NotEmpty(drillDoc.RootElement.GetProperty("measures").EnumerateArray());
}
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ param create_custom_view.name 60
tool delete_custom_view 158
param delete_custom_view.view_id 54

tool describe_custom_view_catalog 514
tool describe_custom_view_catalog 582
param describe_custom_view_catalog.full_detail 111
param describe_custom_view_catalog.source 186

tool get_custom_view 271
param get_custom_view.view_id 61
Expand Down
6 changes: 5 additions & 1 deletion Darling/Darling.Tests/McpToolsListBudgetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ so neither counts here. */
/* #4198 (lane TB): +364 bytes for get_deadlock_detail's default-preview note in its served description
and its new full_graph opt-in parameter (deadlock_graph_xml, the wide field, is now a 2000-char
preview by default). */
private const int TotalCeilingBytes = 172_220;
/* #4198 (lane TC): +472 bytes for describe_custom_view_catalog's default-is-compact note in its served
description and its two new opt-ins, source and full_detail (the catalog, 98,173 bytes at default
arguments, is now grouped-by-source with most per-measure fields dropped by default; source drills
into one source's full detail, full_detail returns the original shape). */
private const int TotalCeilingBytes = 172_692;

private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
Expand Down
Loading
Loading