diff --git a/Darling/Darling.Tests/McpToolGuideHeads.Trend.cs b/Darling/Darling.Tests/McpToolGuideHeads.Trend.cs new file mode 100644 index 0000000000..46dea8b5a3 --- /dev/null +++ b/Darling/Darling.Tests/McpToolGuideHeads.Trend.cs @@ -0,0 +1,90 @@ +/* + * 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. + */ + +using System; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using Xunit; + +namespace Darling.Tests; + +/// +/// #3898 D3 head pins for the "Trend" family: get_query_store_duration_trend and +/// get_perfmon_trend, converted out of DarlingMcpTrendTools (Darling) and their Lite twins. +/// Follows the pattern in . +/// +public sealed class McpToolGuideHeadsTrendTests +{ + private static readonly string[] ConvertedTools = + [ + "get_perfmon_trend", + "get_query_store_duration_trend", + ]; + + /// The per-tool guardrail phrase each head must state. + private static readonly (string Tool, string Fact)[] HeadFacts = + [ + ("get_perfmon_trend", "No points never returns empty"), + ("get_perfmon_trend", "never delta_value alone or when the interval is 0"), + ("get_perfmon_trend", "classify by name (ends in /sec = rate)"), + ("get_query_store_duration_trend", "not_collected: engine cannot run Query Store."), + ("get_query_store_duration_trend", "unavailable: never sampled here."), + ("get_query_store_duration_trend", "empty: quiet on Lite always; on Darling, empty can also be a rollup coverage gap"), + ("get_query_store_duration_trend", "each interval counted once, at the hour it ran"), + ("get_query_store_duration_trend", "window_truncated marks the retention floor, not a page cut"), + ]; + + [Fact] + public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer() + { + foreach (var tool in ConvertedTools) + { + var served = McpToolGuideTests.Served(tool); + Assert.NotNull(served.Tail); + Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal); + Assert.True(served.Served.Length <= 620, $"{tool}: served head {served.Served.Length} is over the 620 target"); + Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{tool}.{p.Parameter}: {p.Length} > 200")); + } + + foreach (var (tool, fact) in HeadFacts) + { + Assert.Contains(fact, McpToolGuideTests.Served(tool).Served, StringComparison.Ordinal); + } + } + + /// get_perfmon_trend's miss vocabulary is not_collected/unavailable only: unlike its trend + /// siblings it never returns status empty (#4098-era D9 trap). The head must say so, and the tail (the + /// original prose, unchanged) must still carry the four counter_kind branches behind it. + [Fact] + public void PerfmonTrend_NeverEmpty_AndTailKeepsAllFourCounterKinds() + { + var served = McpToolGuideTests.Served("get_perfmon_trend"); + Assert.Contains("No points never returns empty:", served.Served, StringComparison.Ordinal); + Assert.Contains("unavailable: no counter at all collected in the window.", served.Served, StringComparison.Ordinal); + Assert.StartsWith("Gets one performance counter over time in time buckets.", served.Tail!, StringComparison.Ordinal); + Assert.EndsWith(BaselineDiscontinuities.DescriptionSentence, served.Tail!, StringComparison.Ordinal); + foreach (var kind in new[] { "'gauge'", "'rate'", "'other'" }) + { + Assert.Contains(kind, served.Tail!, StringComparison.Ordinal); + } + } + + /// get_query_store_duration_trend's empty answer means different things on the two products + /// (Darling has a rollup tier that can leave a coverage gap; Lite has none, so its empty is always a + /// genuinely quiet window): the shared head states both in one clause per D6, and each product's own + /// tail (the original prose, unchanged) keeps its own routing detail. + [Fact] + public void QueryStoreDurationTrend_TailKeepsItsOwnRoutingDetail_DarlingSide() + { + var served = McpToolGuideTests.Served("get_query_store_duration_trend"); + Assert.StartsWith("Gets a time-series of Query Store duration per second and executions per second over time, summed across every query.", served.Tail!, StringComparison.Ordinal); + Assert.Contains("A rollup point (an hourly bucket the corrected rollup has materialized) is rated over its bucket width", served.Tail!, StringComparison.Ordinal); + Assert.EndsWith(BaselineDiscontinuities.DescriptionSentence, served.Tail!, StringComparison.Ordinal); + Assert.Contains("window_truncated is true when the store did not hold the start of the window", served.Tail!, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpTrendTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpTrendTools.txt index 00443e14ec..3736be8e37 100644 --- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpTrendTools.txt +++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpTrendTools.txt @@ -12,7 +12,7 @@ param get_memory_trend.bucket_minutes 55 param get_memory_trend.hours_back 29 param get_memory_trend.server_name 28 -tool get_perfmon_trend 1057 +tool get_perfmon_trend 615 param get_perfmon_trend.as_of 167 param get_perfmon_trend.bucket_minutes 55 param get_perfmon_trend.counter_name 50 @@ -31,7 +31,7 @@ param get_query_duration_trend.bucket_minutes 55 param get_query_duration_trend.hours_back 29 param get_query_duration_trend.server_name 28 -tool get_query_store_duration_trend 1537 +tool get_query_store_duration_trend 616 param get_query_store_duration_trend.as_of 167 param get_query_store_duration_trend.hours_back 29 param get_query_store_duration_trend.server_name 28 diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs index 2f918684ec..0f537309ca 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs @@ -164,7 +164,7 @@ appears where snapshots exist with nothing granted. Lite's tool builds the same } } - [McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)] + [McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in buckets, ending at as_of. counter_kind says the unit: gauge - value is the bucket average, delta_value and sample_interval_seconds null (no delta for a level); rate - per-second is delta_value divided by sample_interval_seconds, never delta_value alone or when the interval is 0; other - delta_value is a non-rate change; null - classify by name (ends in /sec = rate). No points never returns empty: not_collected covers a gated engine, Page Life Expectancy, or an unknown counter name; unavailable: no counter at all collected in the window. <> Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)] public static Task GetPerfmonTrend( NpgsqlDataSource postgres, [Description("The exact counter name, e.g. 'Batch Requests/sec'.")] string counter_name, @@ -614,7 +614,7 @@ width rule on the hourly tier. */ } } - [McpServerTool(Name = "get_query_store_duration_trend"), Description("Gets a time-series of Query Store duration per second and executions per second over time, summed across every query. Where get_query_duration_trend reads the plan cache and loses everything an eviction or a restart takes with it, this reads Query Store, which persists per interval - so it is the series that survives a failover and the one to reach for when a regression is older than the cache. Each interval is counted once, at the hour the work ran. A rollup point (an hourly bucket the corrected rollup has materialized) is rated over its bucket width, so every rollup point is rated, the window's first bucket included; a raw point (a Query Store interval placed at its start, or a legacy row at its collection time) is rated over the gap since the PREVIOUS point, so a raw point that is first in the window - with no previous one to difference against - carries null rates: unknowable, never reported as 0 (unrated_points counts them, unrated_note says why)." + McpHelpers.WindowTruncatedDescription + BaselineDiscontinuities.DescriptionSentence)] + [McpServerTool(Name = "get_query_store_duration_trend"), Description("Gets a time-series of Query Store duration and executions per second, summed across every query, each interval counted once, at the hour it ran. not_collected: engine cannot run Query Store. unavailable: never sampled here. empty: quiet on Lite always; on Darling, empty can also be a rollup coverage gap (window predates the corrected rollup, run --backfill-rollups). A point with no earlier point to rate against has null rates, never 0 (unrated_points, unrated_note says why). window_truncated marks the retention floor, not a page cut; effective_start says where the answer begins. <> Gets a time-series of Query Store duration per second and executions per second over time, summed across every query. Where get_query_duration_trend reads the plan cache and loses everything an eviction or a restart takes with it, this reads Query Store, which persists per interval - so it is the series that survives a failover and the one to reach for when a regression is older than the cache. Each interval is counted once, at the hour the work ran. A rollup point (an hourly bucket the corrected rollup has materialized) is rated over its bucket width, so every rollup point is rated, the window's first bucket included; a raw point (a Query Store interval placed at its start, or a legacy row at its collection time) is rated over the gap since the PREVIOUS point, so a raw point that is first in the window - with no previous one to difference against - carries null rates: unknowable, never reported as 0 (unrated_points counts them, unrated_note says why)." + McpHelpers.WindowTruncatedDescription + BaselineDiscontinuities.DescriptionSentence)] public static async Task GetQueryStoreDurationTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, diff --git a/Lite.Tests/McpToolGuideHeads.Trend.cs b/Lite.Tests/McpToolGuideHeads.Trend.cs new file mode 100644 index 0000000000..c2f481f6a7 --- /dev/null +++ b/Lite.Tests/McpToolGuideHeads.Trend.cs @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using Xunit; + +namespace Lite.Tests; + +/// +/// #3898 D3 head pins for the "Trend" family's Lite twins: get_query_store_duration_trend and +/// get_perfmon_trend. Darling's twin is Darling.Tests/McpToolGuideHeadsTrendTests, which also +/// holds the cross-SKU lockstep pin. +/// +public sealed class McpToolGuideHeadsTrendTests +{ + private static readonly string[] ConvertedTools = + [ + "get_perfmon_trend", + "get_query_store_duration_trend", + ]; + + /// The per-tool guardrail phrase each head must state. + private static readonly (string Tool, string Fact)[] HeadFacts = + [ + ("get_perfmon_trend", "No points never returns empty"), + ("get_perfmon_trend", "never delta_value alone or when the interval is 0"), + ("get_perfmon_trend", "classify by name (ends in /sec = rate)"), + ("get_query_store_duration_trend", "not_collected: engine cannot run Query Store."), + ("get_query_store_duration_trend", "unavailable: never sampled here."), + ("get_query_store_duration_trend", "empty: quiet on Lite always; on Darling, empty can also be a rollup coverage gap"), + ("get_query_store_duration_trend", "each interval counted once, at the hour it ran"), + ("get_query_store_duration_trend", "window_truncated marks the retention floor, not a page cut"), + ]; + + [Fact] + public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer() + { + foreach (var tool in ConvertedTools) + { + var served = McpToolGuideTests.Served(tool); + Assert.NotNull(served.Tail); + Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal); + Assert.True(served.Served.Length <= 620, $"{tool}: served head {served.Served.Length} is over the 620 target"); + Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{tool}.{p.Parameter}: {p.Length} > 200")); + } + + foreach (var (tool, fact) in HeadFacts) + { + Assert.Contains(fact, McpToolGuideTests.Served(tool).Served, StringComparison.Ordinal); + } + } + + /// get_perfmon_trend's miss vocabulary is not_collected/unavailable only, byte-identical on both + /// products: it never returns status empty. The tail (the original prose, unchanged) still carries the + /// four counter_kind branches behind the head's terse summary. + [Fact] + public void PerfmonTrend_NeverEmpty_AndTailKeepsAllFourCounterKinds() + { + var served = McpToolGuideTests.Served("get_perfmon_trend"); + Assert.Contains("No points never returns empty:", served.Served, StringComparison.Ordinal); + Assert.Contains("unavailable: no counter at all collected in the window.", served.Served, StringComparison.Ordinal); + Assert.StartsWith("Gets one performance counter over time in time buckets.", served.Tail!, StringComparison.Ordinal); + Assert.EndsWith(BaselineDiscontinuities.DescriptionSentence, served.Tail!, StringComparison.Ordinal); + foreach (var kind in new[] { "'gauge'", "'rate'", "'other'" }) + { + Assert.Contains(kind, served.Tail!, StringComparison.Ordinal); + } + } + + /// Lite has no rollup tier, so get_query_store_duration_trend's empty is always a genuinely + /// quiet window there; the shared head says so in the same clause that states Darling's rollup-coverage-gap + /// case (D6), and Lite's own tail (the original prose, unchanged) never mentions a rollup. + [Fact] + public void QueryStoreDurationTrend_TailKeepsItsOwnRoutingDetail_LiteSide() + { + var served = McpToolGuideTests.Served("get_query_store_duration_trend"); + Assert.StartsWith("Gets a time-series of Query Store duration per second and executions per second over time, summed across every query.", served.Tail!, StringComparison.Ordinal); + Assert.Contains("Every point is a rate over the gap since the PREVIOUS point", served.Tail!, StringComparison.Ordinal); + Assert.DoesNotContain("rollup", served.Tail!, StringComparison.Ordinal); + Assert.EndsWith(BaselineDiscontinuities.DescriptionSentence, served.Tail!, StringComparison.Ordinal); + } +} diff --git a/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt b/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt index 8adfb9cfc2..a522826c98 100644 --- a/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt @@ -4,7 +4,7 @@ param get_perfmon_stats.counter_name 61 param get_perfmon_stats.instance_name 57 param get_perfmon_stats.server_name 28 -tool get_perfmon_trend 1057 +tool get_perfmon_trend 615 param get_perfmon_trend.as_of 167 param get_perfmon_trend.bucket_minutes 55 param get_perfmon_trend.counter_name 50 diff --git a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt index 26293f37aa..f34741e39a 100644 --- a/Lite.Tests/McpToolsListBudget/McpQueryTools.txt +++ b/Lite.Tests/McpToolsListBudget/McpQueryTools.txt @@ -20,7 +20,7 @@ param get_query_heatmap.limit 195 param get_query_heatmap.metric 123 param get_query_heatmap.server_name 28 -tool get_query_store_duration_trend 1276 +tool get_query_store_duration_trend 616 param get_query_store_duration_trend.as_of 167 param get_query_store_duration_trend.hours_back 29 param get_query_store_duration_trend.server_name 28 diff --git a/Lite/Mcp/McpPerfmonTools.cs b/Lite/Mcp/McpPerfmonTools.cs index 7d48c52f9a..a7e127d3e7 100644 --- a/Lite/Mcp/McpPerfmonTools.cs +++ b/Lite/Mcp/McpPerfmonTools.cs @@ -63,7 +63,7 @@ public static async Task GetPerfmonStats( } } - [McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)] + [McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in buckets, ending at as_of. counter_kind says the unit: gauge - value is the bucket average, delta_value and sample_interval_seconds null (no delta for a level); rate - per-second is delta_value divided by sample_interval_seconds, never delta_value alone or when the interval is 0; other - delta_value is a non-rate change; null - classify by name (ends in /sec = rate). No points never returns empty: not_collected covers a gated engine, Page Life Expectancy, or an unknown counter name; unavailable: no counter at all collected in the window. <> Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)] public static async Task GetPerfmonTrend( LocalDataService dataService, ServerManager serverManager, diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index fdb65339a6..3672aa475d 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -760,7 +760,7 @@ public static async Task GetProcedureDurationTrend( } } - [McpServerTool(Name = "get_query_store_duration_trend"), Description("Gets a time-series of Query Store duration per second and executions per second over time, summed across every query. Where get_query_duration_trend reads the plan cache and loses everything an eviction or a restart takes with it, this reads Query Store, which persists per interval - so it is the series that survives a failover and the one to reach for when a regression is older than the cache. Each interval is counted once, at the hour the work ran. Every point is a rate over the gap since the PREVIOUS point, so the window's first collection - which has no previous one to difference against - carries null rates: unknowable, never reported as 0 (unrated_points counts them, unrated_note says why)." + McpHelpers.WindowTruncatedDescription + BaselineDiscontinuities.DescriptionSentence)] + [McpServerTool(Name = "get_query_store_duration_trend"), Description("Gets a time-series of Query Store duration and executions per second, summed across every query, each interval counted once, at the hour it ran. not_collected: engine cannot run Query Store. unavailable: never sampled here. empty: quiet on Lite always; on Darling, empty can also be a rollup coverage gap (window predates the corrected rollup, run --backfill-rollups). A point with no earlier point to rate against has null rates, never 0 (unrated_points, unrated_note says why). window_truncated marks the retention floor, not a page cut; effective_start says where the answer begins. <> Gets a time-series of Query Store duration per second and executions per second over time, summed across every query. Where get_query_duration_trend reads the plan cache and loses everything an eviction or a restart takes with it, this reads Query Store, which persists per interval - so it is the series that survives a failover and the one to reach for when a regression is older than the cache. Each interval is counted once, at the hour the work ran. Every point is a rate over the gap since the PREVIOUS point, so the window's first collection - which has no previous one to difference against - carries null rates: unknowable, never reported as 0 (unrated_points counts them, unrated_note says why)." + McpHelpers.WindowTruncatedDescription + BaselineDiscontinuities.DescriptionSentence)] public static async Task GetQueryStoreDurationTrend( LocalDataService dataService, ServerManager serverManager,