Skip to content
Merged
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
67 changes: 67 additions & 0 deletions Darling/Darling.Tests/McpToolGuideHeads.LongQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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.Common;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #3898 D3 head pin for <c>get_long_query_completions</c> (a twin, served byte-identical on both products).
/// Follows the pattern in <see cref="McpToolGuideHeadsHealthParserTests"/>. Lite's twin is
/// <c>Lite.Tests/McpToolGuideHeadsLongQueryTests</c>; the cross-SKU lockstep pin itself is the generic one in
/// <see cref="McpToolGuideTests"/>.
/// </summary>
public sealed class McpToolGuideHeadsLongQueryTests
{
private const string Tool = "get_long_query_completions";

/// <summary>D9: the page is duration-RANKED, so it is the window's SLOWEST rows, never its newest, and the
/// two returned-event-time stamps bound how old the slowest runs are, not how far back the read reached. A
/// truncated page cut rows that are no slower than the page: D9 round 1 read truncated as "some of the very
/// slowest are missing".</summary>
private const string PageIsSlowestFact =
"THE PAGE IS THE window's limit SLOWEST, NOT ITS NEWEST: truncated means the window held more, none " +
"slower than the page; oldest/newest_returned_event_time bound the slowest runs' " +
"ages, NOT how far the read reached.";

/// <summary>D9: the collector is opt-in and OFF by default, so an empty answer is ambiguous between a
/// genuinely quiet window and a collector that was off: never switched on, or switched off since. "Never enabled"
/// was too narrow; a collector switched off yesterday leaves the last 24 hours empty too.</summary>
private const string CollectorGateFact =
"Collector is opt-in, OFF by default: empty can mean none in the window, or the collector was off";

[Fact]
public void Head_CarriesThePageRankingTrap_TheCollectorGate_AndThePointer()
{
var served = McpToolGuideTests.Served(Tool);
Assert.NotNull(served.Tail);
Assert.Contains(PageIsSlowestFact, served.Served, StringComparison.Ordinal);
Assert.Contains(CollectorGateFact, served.Served, StringComparison.Ordinal);
/* Attentions carry no duration and still sort last under the DESC ranking; a null duration_ms on an
otherwise-populated row is that, not a read gone wrong. */
Assert.Contains("attentions (no duration) last", served.Served, StringComparison.Ordinal);
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"));
}

/// <summary>Nothing dropped: every original sentence rides in the tail, including the two the head compresses
/// away entirely (the field list, and the cross-SKU population note).</summary>
[Fact]
public void Tail_KeepsEveryOriginalSentence_TheHeadCompressedAway()
{
var tail = McpToolGuideTests.Served(Tool).Tail!;
Assert.Contains("Shows duration, CPU, reads/writes, row count, result (OK/Error/Abort", tail, StringComparison.Ordinal);
Assert.Contains("Both SKUs keep the same population.", tail, StringComparison.Ordinal);
Assert.Contains(
"The collector is OFF by default; if it returns empty, enable the 'long_query_completions' collector in the schedule.",
tail, StringComparison.Ordinal);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# DarlingMcpLongQueryTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
tool get_long_query_completions 1037
tool get_long_query_completions 604
param get_long_query_completions.as_of 167
param get_long_query_completions.hours_back 29
param get_long_query_completions.limit 134
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ namespace PerformanceMonitor.Darling.Service.Mcp;
[McpServerToolType]
public sealed class DarlingMcpLongQueryTools
{
[McpServerTool(Name = "get_long_query_completions"), Description("Gets the SLOWEST long-running query completions in the window, captured by the opt-in long-query trace: rpc/batch completions whose duration exceeded the trace threshold, plus attentions (client cancels / query timeouts), ranked by duration DESC with attentions (no duration) last. Shows duration, CPU, reads/writes, row count, result (OK/Error/Abort — Abort means the long query was cancelled), the statement text, and the calling session/app/login. THE PAGE IS THE window's limit SLOWEST, NOT ITS NEWEST: completions_returned is how many rows you got and truncated says the window held more than limit. Because the page is duration-RANKED, oldest_returned_event_time / newest_returned_event_time tell you how old the slowest runs are and say NOTHING about how far back the read reached — every row in the window was a candidate, so a truncated page still holds the window's slowest. Both SKUs keep the same population. The collector is OFF by default; if it returns empty, enable the 'long_query_completions' collector in the schedule.")]
[McpServerTool(Name = "get_long_query_completions"), Description("Gets the SLOWEST long-running query completions in the window: rpc/batch completions over the trace's duration threshold, plus attentions (cancels/timeouts), ranked duration DESC, attentions (no duration) last. THE PAGE IS THE window's limit SLOWEST, NOT ITS NEWEST: truncated means the window held more, none slower than the page; oldest/newest_returned_event_time bound the slowest runs' ages, NOT how far the read reached. Collector is opt-in, OFF by default: empty can mean none in the window, or the collector was off — enable 'long_query_completions' in the schedule. <<GUIDE>> Gets the SLOWEST long-running query completions in the window, captured by the opt-in long-query trace: rpc/batch completions whose duration exceeded the trace threshold, plus attentions (client cancels / query timeouts), ranked by duration DESC with attentions (no duration) last. Shows duration, CPU, reads/writes, row count, result (OK/Error/Abort — Abort means the long query was cancelled), the statement text, and the calling session/app/login. THE PAGE IS THE window's limit SLOWEST, NOT ITS NEWEST: completions_returned is how many rows you got and truncated says the window held more than limit. Because the page is duration-RANKED, oldest_returned_event_time / newest_returned_event_time tell you how old the slowest runs are and say NOTHING about how far back the read reached — every row in the window was a candidate, so a truncated page still holds the window's slowest. Both SKUs keep the same population. The collector is OFF by default; if it returns empty, enable the 'long_query_completions' collector in the schedule.")]
public static async Task<string> GetLongQueryCompletions(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
Expand Down
65 changes: 65 additions & 0 deletions Lite.Tests/McpToolGuideHeads.LongQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* 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.Common;
using Xunit;

namespace Lite.Tests;

/// <summary>
/// #3898 D3 head pin for <c>get_long_query_completions</c>'s Lite twin. Darling's twin is
/// <c>Darling.Tests/McpToolGuideHeadsLongQueryTests</c>, which also holds the cross-SKU lockstep pin.
/// </summary>
public sealed class McpToolGuideHeadsLongQueryTests
{
private const string Tool = "get_long_query_completions";

/// <summary>D9: the page is duration-RANKED, so it is the window's SLOWEST rows, never its newest, and the
/// two returned-event-time stamps bound how old the slowest runs are, not how far back the read reached. A
/// truncated page cut rows that are no slower than the page: D9 round 1 read truncated as "some of the very
/// slowest are missing".</summary>
private const string PageIsSlowestFact =
"THE PAGE IS THE window's limit SLOWEST, NOT ITS NEWEST: truncated means the window held more, none " +
"slower than the page; oldest/newest_returned_event_time bound the slowest runs' " +
"ages, NOT how far the read reached.";

/// <summary>D9: the collector is opt-in and OFF by default, so an empty answer is ambiguous between a
/// genuinely quiet window and a collector that was off: never switched on, or switched off since. "Never enabled"
/// was too narrow; a collector switched off yesterday leaves the last 24 hours empty too.</summary>
private const string CollectorGateFact =
"Collector is opt-in, OFF by default: empty can mean none in the window, or the collector was off";

[Fact]
public void Head_CarriesThePageRankingTrap_TheCollectorGate_AndThePointer()
{
var served = McpToolGuideTests.Served(Tool);
Assert.NotNull(served.Tail);
Assert.Contains(PageIsSlowestFact, served.Served, StringComparison.Ordinal);
Assert.Contains(CollectorGateFact, served.Served, StringComparison.Ordinal);
/* Attentions carry no duration and still sort last under the DESC ranking; a null duration_ms on an
otherwise-populated row is that, not a read gone wrong. */
Assert.Contains("attentions (no duration) last", served.Served, StringComparison.Ordinal);
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"));
}

/// <summary>Nothing dropped: every original sentence rides in the tail, including the two the head compresses
/// away entirely (the field list, and the cross-SKU population note).</summary>
[Fact]
public void Tail_KeepsEveryOriginalSentence_TheHeadCompressedAway()
{
var tail = McpToolGuideTests.Served(Tool).Tail!;
Assert.Contains("Shows duration, CPU, reads/writes, row count, result (OK/Error/Abort", tail, StringComparison.Ordinal);
Assert.Contains("Both SKUs keep the same population.", tail, StringComparison.Ordinal);
Assert.Contains(
"The collector is OFF by default; if it returns empty, enable the 'long_query_completions' collector in the schedule.",
tail, StringComparison.Ordinal);
}
}
2 changes: 1 addition & 1 deletion Lite.Tests/McpToolsListBudget/McpLongQueryTools.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# McpLongQueryTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
tool get_long_query_completions 1037
tool get_long_query_completions 604
param get_long_query_completions.as_of 167
param get_long_query_completions.hours_back 29
param get_long_query_completions.limit 134
Expand Down
2 changes: 1 addition & 1 deletion Lite/Mcp/McpLongQueryTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace PerformanceMonitorLite.Mcp;
[McpServerToolType]
public sealed class McpLongQueryTools
{
[McpServerTool(Name = "get_long_query_completions"), Description("Gets the SLOWEST long-running query completions in the window, captured by the opt-in long-query trace: rpc/batch completions whose duration exceeded the trace threshold, plus attentions (client cancels / query timeouts), ranked by duration DESC with attentions (no duration) last. Shows duration, CPU, reads/writes, row count, result (OK/Error/Abort — Abort means the long query was cancelled), the statement text, and the calling session/app/login. THE PAGE IS THE window's limit SLOWEST, NOT ITS NEWEST: completions_returned is how many rows you got and truncated says the window held more than limit. Because the page is duration-RANKED, oldest_returned_event_time / newest_returned_event_time tell you how old the slowest runs are and say NOTHING about how far back the read reached — every row in the window was a candidate, so a truncated page still holds the window's slowest. Both SKUs keep the same population. The collector is OFF by default; if it returns empty, enable the 'long_query_completions' collector in the schedule.")]
[McpServerTool(Name = "get_long_query_completions"), Description("Gets the SLOWEST long-running query completions in the window: rpc/batch completions over the trace's duration threshold, plus attentions (cancels/timeouts), ranked duration DESC, attentions (no duration) last. THE PAGE IS THE window's limit SLOWEST, NOT ITS NEWEST: truncated means the window held more, none slower than the page; oldest/newest_returned_event_time bound the slowest runs' ages, NOT how far the read reached. Collector is opt-in, OFF by default: empty can mean none in the window, or the collector was off — enable 'long_query_completions' in the schedule. <<GUIDE>> Gets the SLOWEST long-running query completions in the window, captured by the opt-in long-query trace: rpc/batch completions whose duration exceeded the trace threshold, plus attentions (client cancels / query timeouts), ranked by duration DESC with attentions (no duration) last. Shows duration, CPU, reads/writes, row count, result (OK/Error/Abort — Abort means the long query was cancelled), the statement text, and the calling session/app/login. THE PAGE IS THE window's limit SLOWEST, NOT ITS NEWEST: completions_returned is how many rows you got and truncated says the window held more than limit. Because the page is duration-RANKED, oldest_returned_event_time / newest_returned_event_time tell you how old the slowest runs are and say NOTHING about how far back the read reached — every row in the window was a candidate, so a truncated page still holds the window's slowest. Both SKUs keep the same population. The collector is OFF by default; if it returns empty, enable the 'long_query_completions' collector in the schedule.")]
public static async Task<string> GetLongQueryCompletions(
LocalDataService dataService,
ServerManager serverManager,
Expand Down
Loading