From 0d0600575d46b5b9de47b7e9f464a3c661017969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 23 Sep 2026 15:10:27 +0200 Subject: [PATCH 1/7] Separate query execution from continuation token export Fixes #219 --- CHANGELOG.md | 1 + .../CommandTests/QueryCommandTests.cs | 212 ++++++++++++++++++ .../McpResponseFactoryTests.cs | 33 +++ .../QueryCommand.cs | 49 +++- .../CommandState.cs | 7 + .../McpResponseFactory.cs | 5 + CosmosDBShell/lang/en.ftl | 1 + docs/commands.md | 2 + docs/mcp.md | 3 + l10n/CosmosDBShell.json | 1 + 10 files changed, 310 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d25a26a4..a379c424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixes +- Vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections without a matching `ORDER BY` no longer fail with a continuation-token error. These query pipelines execute successfully but cannot export a resumable token, which was previously reported as a command failure. Such queries now return their documents; through MCP they keep reading until the requested limit instead of stopping after one page, and a truncated result is reported as `resultIncomplete` rather than as an exhausted result set. ([#219](https://github.com/Azure/CosmosDBShell/issues/219)) - Local emulator outages are now detected across Cosmos DB commands. Requests fail promptly with an error and return the shell to its disconnected state instead of leaving an unresponsive session labeled as connected. ## 1.1.209-preview — 2026-08-26 diff --git a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs index b9db54fc..6e818637 100644 --- a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs @@ -5,10 +5,14 @@ namespace CosmosShell.Tests.CommandTests; using System.Globalization; +using System.Net; +using System.Text; using System.Text.Json; using Azure.Data.Cosmos.Shell.Commands; using Azure.Data.Cosmos.Shell.Core; using Microsoft.Azure.Cosmos; +using NSubstitute; +using Spectre.Console; public class QueryCommandTests { @@ -544,4 +548,212 @@ public void ParseIndexPlan_RecognizedEmptyGroups_ReturnsAvailable() Assert.Empty(utilized); Assert.Empty(potential); } + + [Fact] + public void TryReadContinuationToken_ResponseWithoutToken_ReportsSupported() + { + using var response = new ResponseMessage(HttpStatusCode.OK); + + Assert.True(QueryCommand.TryReadContinuationToken(response, out var continuationToken)); + Assert.Null(continuationToken); + } + + [Fact] + public void TryReadContinuationToken_ResponseWithToken_ReturnsToken() + { + using var response = new PageResponse("{\"_count\":0,\"Documents\":[]}", () => "next-page"); + + Assert.True(QueryCommand.TryReadContinuationToken(response, out var continuationToken)); + Assert.Equal("next-page", continuationToken); + } + + [Theory] + [InlineData("Continuation tokens are not supported for the non streaming order by pipeline.")] + [InlineData("Continuation tokens are not supported by hybrid search.")] + [InlineData("DISTINCT queries only return continuation tokens when there is a matching ORDER BY clause.")] + public void TryReadContinuationToken_PipelineWithoutTokenSupport_ReportsUnsupported(string message) + { + using var response = new PageResponse("{\"_count\":0,\"Documents\":[]}", () => throw new ArgumentException(message)); + + Assert.False(QueryCommand.TryReadContinuationToken(response, out var continuationToken)); + Assert.Null(continuationToken); + } + + [Fact] + public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_ReturnsDocuments() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + NonResumablePage("Continuation tokens are not supported for the non streaming order by pipeline.", 1.5, "1", "2", "6")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT TOP 3 c.id FROM c ORDER BY VectorDistance(c.embedding, [1,0,0])", Max = 10 }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(["1", "2", "6"], ReadIds(result)); + Assert.Null(result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + Assert.Equal(1.5, result.RequestCharge); + } + + [Fact] + public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_AdvancesThroughEmptyPages() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "1"), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 2), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 3, "2", "6")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT TOP 3 c.id FROM c ORDER BY RANK FullTextScore(c.text, \"cosmos\")", Max = 10, IsMcpRequest = true }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(["1", "2", "6"], ReadIds(result)); + Assert.Equal(3, iterator.ReadCount); + Assert.Null(result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + Assert.Equal(6, result.RequestCharge); + } + + [Fact] + public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_ReportsIncompleteResultAtLimit() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "1", "2"), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "6")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT c.id FROM c ORDER BY RANK FullTextScore(c.text, \"cosmos\")", Max = 2, IsMcpRequest = true }; + + var output = await CaptureConsoleAsync(() => command.ExecuteQueryAsync(container, shell, CancellationToken.None)); + + Assert.Equal(["1", "2"], ReadIds(output.Result)); + Assert.Null(output.Result.ContinuationToken); + Assert.True(output.Result.IncompleteWithoutContinuation); + Assert.Contains("cannot be resumed", output.Text); + } + + [Fact] + public async Task ExecuteQueryAsync_ResumablePage_KeepsSingleMcpPageAndToken() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + ResumablePage("next-page", 1, "A", "B"), + ResumablePage(null, 1, "C")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT DISTINCT VALUE c.category FROM c ORDER BY c.category", Max = 10, IsMcpRequest = true }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(1, iterator.ReadCount); + Assert.Equal("next-page", result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + } + + [Fact] + public async Task ExecuteQueryAsync_ResumableQueryAtLimit_ReportsLimitWithoutResumeWarning() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + ResumablePage("next-page", 1, "1", "2", "3")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT * FROM c", Max = 2 }; + + var output = await CaptureConsoleAsync(() => command.ExecuteQueryAsync(container, shell, CancellationToken.None)); + + Assert.Equal(["1", "2"], ReadIds(output.Result)); + Assert.Equal("next-page", output.Result.ContinuationToken); + Assert.False(output.Result.IncompleteWithoutContinuation); + Assert.Contains("Results limited to 2 items", output.Text); + Assert.DoesNotContain("cannot be resumed", output.Text); + } + + private static Container CreateContainer(FeedIterator iterator) + { + var container = Substitute.For(); + container.GetItemQueryStreamIterator(Arg.Any(), Arg.Any(), Arg.Any()).Returns(iterator); + return container; + } + + private static ResponseMessage NonResumablePage(string message, double requestCharge, params string[] ids) + { + return CreatePage(requestCharge, () => throw new ArgumentException(message), ids); + } + + private static ResponseMessage ResumablePage(string? continuationToken, double requestCharge, params string[] ids) + { + return CreatePage(requestCharge, () => continuationToken, ids); + } + + private static ResponseMessage CreatePage(double requestCharge, Func continuationToken, string[] ids) + { + var documents = string.Join(",", ids.Select(id => $"{{\"id\":\"{id}\"}}")); + var response = new PageResponse($"{{\"_count\":{ids.Length},\"Documents\":[{documents}]}}", continuationToken); + response.Headers.Add("x-ms-request-charge", requestCharge.ToString(CultureInfo.InvariantCulture)); + return response; + } + + private static string[] ReadIds(CommandState state) + { + using var document = JsonDocument.Parse(state.GenerateOutputText()); + return [.. document.RootElement.GetProperty("values").EnumerateArray().Select(value => value.GetProperty("id").GetString()!)]; + } + + private static async Task<(CommandState Result, string Text)> CaptureConsoleAsync(Func> action) + { + var saved = AnsiConsole.Console; + using var writer = new StringWriter(); + try + { + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(writer), + }); + AnsiConsole.Console.Profile.Width = 200; + + var result = await action(); + return (result, writer.ToString()); + } + finally + { + AnsiConsole.Console = saved; + } + } + + private sealed class PageResponse : ResponseMessage + { + private readonly Func continuationToken; + + public PageResponse(string content, Func continuationToken) + : base(HttpStatusCode.OK) + { + this.continuationToken = continuationToken; + this.Content = new MemoryStream(Encoding.UTF8.GetBytes(content)); + } + + public override string ContinuationToken => this.continuationToken()!; + } + + private sealed class FakeFeedIterator : FeedIterator + { + private readonly Queue pages; + + public FakeFeedIterator(params ResponseMessage[] pages) + { + this.pages = new Queue(pages); + } + + public int ReadCount { get; private set; } + + public override bool HasMoreResults => this.pages.Count > 0; + + public override Task ReadNextAsync(CancellationToken cancellationToken = default) + { + this.ReadCount++; + return Task.FromResult(this.pages.Dequeue()); + } + } } \ No newline at end of file diff --git a/CosmosDBShell.Tests/McpResponseFactoryTests.cs b/CosmosDBShell.Tests/McpResponseFactoryTests.cs index 5a85eab5..76b095c4 100644 --- a/CosmosDBShell.Tests/McpResponseFactoryTests.cs +++ b/CosmosDBShell.Tests/McpResponseFactoryTests.cs @@ -249,4 +249,37 @@ public void CreateSuccess_OmitsRequestChargeWhenNotSet() Assert.NotNull(result.StructuredContent); Assert.False(result.StructuredContent!.Value.TryGetProperty("requestCharge", out _)); } + + [Fact] + public void CreateSuccess_IncompletePage_MarksResultIncomplete() + { + var commandState = new CommandState + { + IsPage = true, + IncompleteWithoutContinuation = true, + Result = new ShellJson(JsonSerializer.SerializeToElement(new { result = "success" })), + }; + + var result = McpResponseFactory.CreateSuccess(commandState, new ConnectedState(null!)); + + Assert.NotNull(result.StructuredContent); + var structured = result.StructuredContent!.Value; + Assert.Equal(JsonValueKind.Null, structured.GetProperty("continuationToken").ValueKind); + Assert.True(structured.GetProperty("resultIncomplete").GetBoolean()); + } + + [Fact] + public void CreateSuccess_CompletePage_OmitsResultIncomplete() + { + var commandState = new CommandState + { + IsPage = true, + Result = new ShellJson(JsonSerializer.SerializeToElement(new { result = "success" })), + }; + + var result = McpResponseFactory.CreateSuccess(commandState, new ConnectedState(null!)); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent!.Value.TryGetProperty("resultIncomplete", out _)); + } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs index 82ae72d2..1a0cd30d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs @@ -35,7 +35,7 @@ internal enum MetricTarget ReadOnly = true, Idempotent = true, OpenWorld = true, - Description = "Executes a Cosmos DB NoSQL query against the current container and returns one bounded page of matching documents. Pass the returned continuationToken as continuation to retrieve the next page. Pass explain=true to return the query execution plan (utilized/potential indexes and a plain-language evaluation) instead of documents. Use the cosmos://docs/nosql-query-language resource for query syntax reference.")] + Description = "Executes a Cosmos DB NoSQL query against the current container and returns one bounded page of matching documents. Pass the returned continuationToken as continuation to retrieve the next page. Vector ORDER BY, ORDER BY RANK, and DISTINCT queries without a matching ORDER BY cannot be paged: they return a null continuationToken, and a result truncated by max is reported with resultIncomplete set to true, which means the result set is not exhausted and must be retried with a larger max or a narrower query. Pass explain=true to return the query execution plan (utilized/potential indexes and a plain-language evaluation) instead of documents. Use the cosmos://docs/nosql-query-language resource for query syntax reference.")] internal class QueryCommand : CosmosCommand, IPagedCommand { [CosmosParameter("query")] @@ -135,6 +135,30 @@ internal static bool PageExceedsLimit(int currentCount, JsonElement pageDocument return pageDocuments.GetArrayLength() > remainingCapacity; } + /// + /// Reads the continuation token of a successful query response. Pipelines such as + /// non-streaming ORDER BY, hybrid search, and unordered DISTINCT execute normally but + /// refuse to export a resumable token, which the SDK reports by throwing from the + /// property getter rather than by failing the request. Executing a query and being able + /// to resume it are therefore reported separately. + /// + /// The successful query response. + /// The exported token, or when none is available. + /// when the response can export a token; otherwise . + internal static bool TryReadContinuationToken(ResponseMessage response, out string? continuationToken) + { + try + { + continuationToken = response.ContinuationToken; + return true; + } + catch (ArgumentException) + { + continuationToken = null; + return false; + } + } + internal static CommandState CreateCommandState(string? outputFormat) { var state = new CommandState(); @@ -677,7 +701,7 @@ private async Task ExecuteExplainAsync(Container container, ShellI } } - private async Task ExecuteQueryAsync(Container container, ShellInterpreter shell, CancellationToken token) + internal async Task ExecuteQueryAsync(Container container, ShellInterpreter shell, CancellationToken token) { try { @@ -711,6 +735,7 @@ private async Task ExecuteQueryAsync(Container container, ShellInt using var feedIterator = container.GetItemQueryStreamIterator(this.Query, this.Continuation, options); var limitReached = false; + var continuationSupported = true; while (feedIterator.HasMoreResults) { @@ -750,7 +775,17 @@ private async Task ExecuteQueryAsync(Container container, ShellInt totalRequestCharge += pageRequestCharge; AnsiConsole.MarkupLine(MessageService.GetString("command-query-request_charge", new Dictionary { { "charge", pageRequestCharge.ToString("F2", CultureInfo.InvariantCulture) } })); - returnState.ContinuationToken = response.ContinuationToken; + if (TryReadContinuationToken(response, out var pageContinuationToken)) + { + returnState.ContinuationToken = pageContinuationToken; + } + else + { + // The pipeline cannot be resumed. Drop any token collected from an earlier + // page so the caller never receives one the service would reject. + continuationSupported = false; + returnState.ContinuationToken = null; + } var pageDocuments = queryDocument.RootElement.GetProperty("Documents"); var pageExceedsLimit = PageExceedsLimit(aggregatedDocuments.Count, pageDocuments, effectiveMaxItemCount); @@ -895,7 +930,7 @@ private async Task ExecuteQueryAsync(Container container, ShellInt GeneratePlainResultDocument(returnState, aggregatedDocuments); } - if (this.IsMcpRequest) + if (this.IsMcpRequest && continuationSupported) { break; } @@ -907,9 +942,15 @@ private async Task ExecuteQueryAsync(Container container, ShellInt } } + returnState.IncompleteWithoutContinuation = limitReached && !continuationSupported; + if (limitReached && effectiveMaxItemCount.HasValue) { AnsiConsole.MarkupLine(MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", effectiveMaxItemCount.Value } })); + if (!continuationSupported) + { + AnsiConsole.MarkupLine(MessageService.GetString("command-query-no_continuation")); + } } returnState.RequestCharge = totalRequestCharge; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs index a5ad0fa2..d2864528 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs @@ -79,6 +79,13 @@ public OutputFormat OutputFormat /// internal string? ContinuationToken { get; set; } + /// + /// Gets or sets a value indicating whether the result stops short of the full result set and + /// cannot be resumed. Some Cosmos query pipelines complete successfully but never export a + /// continuation token, so a missing token alone must not be read as an exhausted result set. + /// + internal bool IncompleteWithoutContinuation { get; set; } + /// /// Gets or sets the Cosmos DB request charge (in RUs) consumed by the command, when applicable. /// Data-plane commands set this so consumers such as the MCP structured payload can report cost uniformly. diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs index 955acff7..d634a4ff 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpResponseFactory.cs @@ -115,6 +115,11 @@ private static JsonObject CreateSuccessPayload(CommandState commandState) payload["continuationToken"] = commandState.ContinuationToken; } + if (commandState.IncompleteWithoutContinuation) + { + payload["resultIncomplete"] = true; + } + if (commandState.OutputFormat == OutputFormat.CSV) { var outputText = commandState.GenerateOutputText(); diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index e97f96bc..58106119 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -268,6 +268,7 @@ command-query-description-database = The database to query against command-query-description-container = The container to query against command-query-description-explain = Show the query execution plan (index usage and a plain-language evaluation) instead of returning documents command-query-fetched = Fetched { $count } documents. +command-query-no_continuation = This query cannot be resumed: its execution plan does not return a continuation token, so the remaining results were not retrieved. Raise the limit with --max, use --max 0 for no limit, or narrow the query. command-query-request_charge = Request Charge: { $charge } RUs command-query-document_header = Document command-query-count_header = Count diff --git a/docs/commands.md b/docs/commands.md index ac3ad349..76cf37fb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -394,6 +394,8 @@ Options: When called through MCP, `query` and container-item `ls` return one page at a time. `max` must be positive; omitted or non-positive values use the safe default of `100` items. The MCP result includes `continuationToken`; pass a non-null value back as the tool's `continuation` argument, with the same query and options, to retrieve the next page. A null token means there are no more pages. `continuation` is an MCP-only argument and is deliberately not a shell option, so interactive and scripted commands are unaffected and retain their existing multi-page behavior. +Some query shapes execute normally but cannot be resumed: vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections without a matching `ORDER BY` never produce a continuation token. Such queries run to completion within the requested limit instead of failing. Because they cannot be paged, MCP calls keep reading the same query until the limit is reached rather than returning after one page. If the limit truncates the results, the shell reports that they cannot be resumed and the MCP result sets `resultIncomplete` to `true`; raise `--max`, use `--max 0` for no limit, or narrow the query to get the full result set. + #### Explain a query `query "" --explain` reports how the query engine resolved the query rather than returning documents. When Cosmos DB returns index metrics, it shows whether the query performed a full scan or an index seek, lists the utilized and potential indexes, the index hit ratio, and the request charge. If index metrics are unavailable or unrecognized, the scan type is reported as unknown instead of assuming a full scan. A plain-language summary highlights confirmed full scans and recommends indexes to add. diff --git a/docs/mcp.md b/docs/mcp.md index e05b7ea9..2ea78670 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -128,6 +128,7 @@ Both representations are always byte-for-byte equivalent. | `result` | Commands that produce output | The command result as JSON (objects, arrays, or a scalar). Text-only results are represented as a JSON string. Failed transactional batches include their per-operation summary here alongside `error`. | | `outputText` | CSV output commands with non-empty text | The CSV rendering of the result. Omitted when the CSV output is empty or whitespace. | | `continuationToken` | Paged `query` and container-item `ls` results | Opaque token for the next page, or `null` when no more results are available. Omitted for `query --explain` and database/container name listings, which are not paged. | +| `resultIncomplete` | Truncated results that cannot be resumed | `true` when the result stops at the requested limit and the query cannot produce a continuation token. Omitted otherwise. | | `requestCharge` | Charged data-plane command results | The Cosmos DB request charge (in RUs) consumed by the command, as a number. This is omitted for commands that do not issue a billable request. | | `error` | Failed commands | The error message. | | `currentLocation` | Always | The shell's current navigation path (for example `/MyDatabase/MyContainer`), or `null` when disconnected. | @@ -155,6 +156,8 @@ MCP calls to `query` and container-item `ls` return one Cosmos DB page per call. Because `max` bounds a single page, a call can return fewer items than requested and still have more available; treat a non-null `continuationToken` as the only signal that more results exist. For `ls`, the `result.limitReached` flag reports that same condition and is kept for parity with shell and script output. +Some queries cannot be paged at all. Vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections without a matching `ORDER BY` execute normally but never return a continuation token. For those queries the server keeps reading until `max` is reached and returns a `null` token. If the limit truncated the results, the response also sets `resultIncomplete` to `true`; a `null` token combined with `resultIncomplete` means the result set was **not** exhausted and cannot be resumed. Retry with a larger `max` or a narrower query instead of sending a `continuation`. + ```json { "query": "SELECT * FROM c WHERE c.status = 'active'", diff --git a/l10n/CosmosDBShell.json b/l10n/CosmosDBShell.json index 82d03cd3..809d9173 100644 --- a/l10n/CosmosDBShell.json +++ b/l10n/CosmosDBShell.json @@ -646,6 +646,7 @@ "command-query-index_metrics": "Index Utilization Metric", "command-query-index_score": "Index Impact Score", "command-query-index_spec": "Index Spec", + "command-query-no_continuation": "This query cannot be resumed: its execution plan does not return a continuation token, so the remaining results were not retrieved. Raise the limit with --max, use --max 0 for no limit, or narrow the query.", "command-query-output": "Output", "command-query-query_preparation": "Query preparation", "command-query-request_charge": "Request Charge: {0} RUs", From 6958f98cda05122ab5ab0d41e0f5da2a2dab96ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 23 Sep 2026 15:18:35 +0200 Subject: [PATCH 2/7] Report non-resumable results as incomplete when paging stops early --- .../CommandTests/QueryCommandTests.cs | 26 ++++++++++++++++++- .../QueryCommand.cs | 4 ++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs index 6e818637..fe17fb91 100644 --- a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs @@ -634,6 +634,26 @@ public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_ReportsIncomplet Assert.Contains("cannot be resumed", output.Text); } + [Fact] + public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_CancelledBetweenPages_ReportsIncompleteResult() + { + using var shell = ShellInterpreter.CreateInstance(); + using var cancellation = new CancellationTokenSource(); + using var iterator = new FakeFeedIterator( + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "1"), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "2")); + iterator.AfterRead = cancellation.Cancel; + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT c.id FROM c ORDER BY RANK FullTextScore(c.text, \"cosmos\")", Max = 10, IsMcpRequest = true }; + + var result = await command.ExecuteQueryAsync(container, shell, cancellation.Token); + + Assert.Equal(["1"], ReadIds(result)); + Assert.Equal(1, iterator.ReadCount); + Assert.Null(result.ContinuationToken); + Assert.True(result.IncompleteWithoutContinuation); + } + [Fact] public async Task ExecuteQueryAsync_ResumablePage_KeepsSingleMcpPageAndToken() { @@ -748,12 +768,16 @@ public FakeFeedIterator(params ResponseMessage[] pages) public int ReadCount { get; private set; } + public Action? AfterRead { get; set; } + public override bool HasMoreResults => this.pages.Count > 0; public override Task ReadNextAsync(CancellationToken cancellationToken = default) { this.ReadCount++; - return Task.FromResult(this.pages.Dequeue()); + var page = this.pages.Dequeue(); + this.AfterRead?.Invoke(); + return Task.FromResult(page); } } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs index 1a0cd30d..866e10c4 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs @@ -942,7 +942,9 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn } } - returnState.IncompleteWithoutContinuation = limitReached && !continuationSupported; + // Stopping early without a token leaves results the caller can never retrieve, + // whether the limit or cancellation ended the loop. + returnState.IncompleteWithoutContinuation = !continuationSupported && (limitReached || feedIterator.HasMoreResults); if (limitReached && effectiveMaxItemCount.HasValue) { From 63d14094e04f47c8a65884767c80630ca8f4f9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 23 Sep 2026 15:37:57 +0200 Subject: [PATCH 3/7] Describe non-resumable DISTINCT plans accurately and cover them in tests --- CHANGELOG.md | 2 +- .../CommandTests/QueryCommandTests.cs | 42 ++++++++++++++++--- .../QueryCommand.cs | 11 ++--- docs/commands.md | 2 +- docs/mcp.md | 2 +- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a379c424..864f0416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixes -- Vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections without a matching `ORDER BY` no longer fail with a continuation-token error. These query pipelines execute successfully but cannot export a resumable token, which was previously reported as a command failure. Such queries now return their documents; through MCP they keep reading until the requested limit instead of stopping after one page, and a truncated result is reported as `resultIncomplete` rather than as an exhausted result set. ([#219](https://github.com/Azure/CosmosDBShell/issues/219)) +- Vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and object-shaped `DISTINCT` projections no longer fail with a continuation-token error. These query pipelines execute successfully but cannot export a resumable token, which was previously reported as a command failure. Such queries now return their documents; through MCP they keep reading until the requested limit instead of stopping after one page, and a truncated result is reported as `resultIncomplete` rather than as an exhausted result set. ([#219](https://github.com/Azure/CosmosDBShell/issues/219)) - Local emulator outages are now detected across Cosmos DB commands. Requests fail promptly with an error and return the shell to its disconnected state instead of leaving an unresponsive session labeled as connected. ## 1.1.209-preview — 2026-08-26 diff --git a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs index fe17fb91..2edad5a5 100644 --- a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs @@ -654,6 +654,27 @@ public async Task ExecuteQueryAsync_PipelineWithoutTokenSupport_CancelledBetween Assert.True(result.IncompleteWithoutContinuation); } + [Fact] + public async Task ExecuteQueryAsync_ObjectShapedDistinctWithOrderBy_ReturnsDocumentsWithoutToken() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + NonResumableDocumentPage( + "DISTINCT queries only return continuation tokens when there is a matching ORDER BY clause.", + 1, + "{\"category\":\"A\"}", + "{\"category\":\"B\"}", + "{\"category\":\"C\"}")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT DISTINCT c.category FROM c ORDER BY c.category", Max = 10, IsMcpRequest = true }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(["A", "B", "C"], ReadValues(result, "category")); + Assert.Null(result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + } + [Fact] public async Task ExecuteQueryAsync_ResumablePage_KeepsSingleMcpPageAndToken() { @@ -698,26 +719,35 @@ private static Container CreateContainer(FeedIterator iterator) private static ResponseMessage NonResumablePage(string message, double requestCharge, params string[] ids) { - return CreatePage(requestCharge, () => throw new ArgumentException(message), ids); + return NonResumableDocumentPage(message, requestCharge, [.. ids.Select(id => $"{{\"id\":\"{id}\"}}")]); + } + + private static ResponseMessage NonResumableDocumentPage(string message, double requestCharge, params string[] documents) + { + return CreatePage(requestCharge, () => throw new ArgumentException(message), documents); } private static ResponseMessage ResumablePage(string? continuationToken, double requestCharge, params string[] ids) { - return CreatePage(requestCharge, () => continuationToken, ids); + return CreatePage(requestCharge, () => continuationToken, [.. ids.Select(id => $"{{\"id\":\"{id}\"}}")]); } - private static ResponseMessage CreatePage(double requestCharge, Func continuationToken, string[] ids) + private static ResponseMessage CreatePage(double requestCharge, Func continuationToken, string[] documents) { - var documents = string.Join(",", ids.Select(id => $"{{\"id\":\"{id}\"}}")); - var response = new PageResponse($"{{\"_count\":{ids.Length},\"Documents\":[{documents}]}}", continuationToken); + var response = new PageResponse($"{{\"_count\":{documents.Length},\"Documents\":[{string.Join(",", documents)}]}}", continuationToken); response.Headers.Add("x-ms-request-charge", requestCharge.ToString(CultureInfo.InvariantCulture)); return response; } private static string[] ReadIds(CommandState state) + { + return ReadValues(state, "id"); + } + + private static string[] ReadValues(CommandState state, string property) { using var document = JsonDocument.Parse(state.GenerateOutputText()); - return [.. document.RootElement.GetProperty("values").EnumerateArray().Select(value => value.GetProperty("id").GetString()!)]; + return [.. document.RootElement.GetProperty("values").EnumerateArray().Select(value => value.GetProperty(property).GetString()!)]; } private static async Task<(CommandState Result, string Text)> CaptureConsoleAsync(Func> action) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs index 866e10c4..a6cdd7b4 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs @@ -35,7 +35,7 @@ internal enum MetricTarget ReadOnly = true, Idempotent = true, OpenWorld = true, - Description = "Executes a Cosmos DB NoSQL query against the current container and returns one bounded page of matching documents. Pass the returned continuationToken as continuation to retrieve the next page. Vector ORDER BY, ORDER BY RANK, and DISTINCT queries without a matching ORDER BY cannot be paged: they return a null continuationToken, and a result truncated by max is reported with resultIncomplete set to true, which means the result set is not exhausted and must be retried with a larger max or a narrower query. Pass explain=true to return the query execution plan (utilized/potential indexes and a plain-language evaluation) instead of documents. Use the cosmos://docs/nosql-query-language resource for query syntax reference.")] + Description = "Executes a Cosmos DB NoSQL query against the current container and returns one bounded page of matching documents. Pass the returned continuationToken as continuation to retrieve the next page. Some plans cannot be paged at all, including vector ORDER BY, ORDER BY RANK, and DISTINCT projections such as SELECT DISTINCT c.category FROM c ORDER BY c.category; adding an ORDER BY clause does not make a DISTINCT query resumable. Those queries return a null continuationToken, and a result truncated by max is reported with resultIncomplete set to true, which means the result set is not exhausted and must be retried with a larger max or a narrower query. Pass explain=true to return the query execution plan (utilized/potential indexes and a plain-language evaluation) instead of documents. Use the cosmos://docs/nosql-query-language resource for query syntax reference.")] internal class QueryCommand : CosmosCommand, IPagedCommand { [CosmosParameter("query")] @@ -137,10 +137,11 @@ internal static bool PageExceedsLimit(int currentCount, JsonElement pageDocument /// /// Reads the continuation token of a successful query response. Pipelines such as - /// non-streaming ORDER BY, hybrid search, and unordered DISTINCT execute normally but - /// refuse to export a resumable token, which the SDK reports by throwing from the - /// property getter rather than by failing the request. Executing a query and being able - /// to resume it are therefore reported separately. + /// non-streaming ORDER BY, hybrid search, and DISTINCT execute normally but refuse to + /// export a resumable token, which the SDK reports by throwing from the property getter + /// rather than by failing the request. Whether a token is available is decided by the + /// query plan at runtime, so executing a query and being able to resume it are reported + /// separately instead of being inferred from the query text. /// /// The successful query response. /// The exported token, or when none is available. diff --git a/docs/commands.md b/docs/commands.md index 76cf37fb..69fc5230 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -394,7 +394,7 @@ Options: When called through MCP, `query` and container-item `ls` return one page at a time. `max` must be positive; omitted or non-positive values use the safe default of `100` items. The MCP result includes `continuationToken`; pass a non-null value back as the tool's `continuation` argument, with the same query and options, to retrieve the next page. A null token means there are no more pages. `continuation` is an MCP-only argument and is deliberately not a shell option, so interactive and scripted commands are unaffected and retain their existing multi-page behavior. -Some query shapes execute normally but cannot be resumed: vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections without a matching `ORDER BY` never produce a continuation token. Such queries run to completion within the requested limit instead of failing. Because they cannot be paged, MCP calls keep reading the same query until the limit is reached rather than returning after one page. If the limit truncates the results, the shell reports that they cannot be resumed and the MCP result sets `resultIncomplete` to `true`; raise `--max`, use `--max 0` for no limit, or narrow the query to get the full result set. +Some query shapes execute normally but cannot be resumed: vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections whose execution plan cannot export a token never produce a continuation token. Whether a `DISTINCT` query is resumable is decided by its plan, not by the presence of an `ORDER BY` clause: `SELECT DISTINCT c.category FROM c ORDER BY c.category` is not resumable, while the scalar `SELECT DISTINCT VALUE c.category FROM c ORDER BY c.category` is. Such queries run to completion within the requested limit instead of failing. Because they cannot be paged, MCP calls keep reading the same query until the limit is reached rather than returning after one page. If the limit truncates the results, the shell reports that they cannot be resumed and the MCP result sets `resultIncomplete` to `true`; raise `--max`, use `--max 0` for no limit, or narrow the query to get the full result set. #### Explain a query diff --git a/docs/mcp.md b/docs/mcp.md index 2ea78670..6fe642f1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -156,7 +156,7 @@ MCP calls to `query` and container-item `ls` return one Cosmos DB page per call. Because `max` bounds a single page, a call can return fewer items than requested and still have more available; treat a non-null `continuationToken` as the only signal that more results exist. For `ls`, the `result.limitReached` flag reports that same condition and is kept for parity with shell and script output. -Some queries cannot be paged at all. Vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections without a matching `ORDER BY` execute normally but never return a continuation token. For those queries the server keeps reading until `max` is reached and returns a `null` token. If the limit truncated the results, the response also sets `resultIncomplete` to `true`; a `null` token combined with `resultIncomplete` means the result set was **not** exhausted and cannot be resumed. Retry with a larger `max` or a narrower query instead of sending a `continuation`. +Some queries cannot be paged at all. Vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections whose plan cannot export a token execute normally but never return a continuation token. For `DISTINCT` this is decided by the query plan rather than by the SQL text: `SELECT DISTINCT c.category FROM c ORDER BY c.category` is not resumable even though it has an `ORDER BY`, while the scalar `SELECT DISTINCT VALUE c.category FROM c ORDER BY c.category` is. For those queries the server keeps reading until `max` is reached and returns a `null` token. If the limit truncated the results, the response also sets `resultIncomplete` to `true`; a `null` token combined with `resultIncomplete` means the result set was **not** exhausted and cannot be resumed. Retry with a larger `max` or a narrower query instead of sending a `continuation`. ```json { From a8e274542cd3e06b9a63b765f4527d5f90f3422f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 23 Sep 2026 15:50:31 +0200 Subject: [PATCH 4/7] Qualify end-of-results guidance for non-resumable query plans --- CosmosDBShell.Tests/ToolOperationsTests.cs | 1 + CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs | 2 +- README.md | 2 +- docs/mcp.md | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CosmosDBShell.Tests/ToolOperationsTests.cs b/CosmosDBShell.Tests/ToolOperationsTests.cs index eee41f51..e856b596 100644 --- a/CosmosDBShell.Tests/ToolOperationsTests.cs +++ b/CosmosDBShell.Tests/ToolOperationsTests.cs @@ -48,6 +48,7 @@ public void GetTool_PagedMaxDescription_DocumentsSinglePageSemanticsWithoutChang var maxDescription = schema.GetProperty("properties").GetProperty("max").GetProperty("description").GetString(); Assert.Contains("continuationToken", maxDescription); + Assert.Contains("resultIncomplete", maxDescription); var shellDescription = factory.Options.Single(option => option.Name[0] == "max").GetDescription(commandName); Assert.DoesNotContain("continuationToken", shellDescription); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs index 3d46801d..ae85dac4 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs @@ -247,7 +247,7 @@ internal static bool TrySetContinuation(object command, string argumentName, Jso { var description = option.GetDescription(command.CommandName); return IsPagedMaxOption(command, option) - ? $"{description} Through MCP this must be positive and bounds a single page rather than the whole result set. Omitted or non-positive values use the default of {DefaultPageSize}. A call can return fewer items and still have more available; use continuationToken to detect the end." + ? $"{description} Through MCP this must be positive and bounds a single page rather than the whole result set. Omitted or non-positive values use the default of {DefaultPageSize}. A call can return fewer items and still have more available; a null continuationToken marks the end of the results unless the response sets resultIncomplete, which reports results that were cut off and cannot be resumed." : description; } diff --git a/README.md b/README.md index 16071b67..1c53dd5b 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ Packaging runs produce preview versions in the form `1.0.-preview.` | `--theme ` | Color theme profile to apply at startup (`default`, `light`, `dark`, `monochrome`). Falls back to `COSMOSDB_SHELL_THEME`. | | `--help` | Show help | -MCP `query` and container-item `ls` calls return bounded, resumable pages. They default to at most 100 items and include a continuation token for the next call; see [MCP pagination](docs/mcp.md#pagination). +MCP `query` and container-item `ls` calls return bounded pages. They default to at most 100 items and include a continuation token for the next call. Some query plans cannot be resumed and report `resultIncomplete` instead of a token; see [MCP pagination](docs/mcp.md#pagination). Examples: diff --git a/docs/mcp.md b/docs/mcp.md index 6fe642f1..3c65a739 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -150,11 +150,11 @@ session object also includes `session.requestChargeWarningThreshold`. ### Pagination -MCP calls to `query` and container-item `ls` return one Cosmos DB page per call. `max` must be positive; when it is omitted or non-positive, the server applies a safe default cap of `100` items. To continue, pass a non-null returned `continuationToken` as the next call's `continuation` argument while keeping the query and other options unchanged. The token is opaque; do not parse or edit it. When the token is `null`, the result set is exhausted: stop paging and do not send another request with `continuation`. +MCP calls to `query` and container-item `ls` return one Cosmos DB page per call. `max` must be positive; when it is omitted or non-positive, the server applies a safe default cap of `100` items. To continue, pass a non-null returned `continuationToken` as the next call's `continuation` argument while keeping the query and other options unchanged. The token is opaque; do not parse or edit it. When the token is `null` and the response does not set `resultIncomplete`, the result set is exhausted: stop paging and do not send another request with `continuation`. `continuation` is exposed only to MCP callers — there is no corresponding shell option, and the token is never echoed into the shell's command output. -Because `max` bounds a single page, a call can return fewer items than requested and still have more available; treat a non-null `continuationToken` as the only signal that more results exist. For `ls`, the `result.limitReached` flag reports that same condition and is kept for parity with shell and script output. +Because `max` bounds a single page, a call can return fewer items than requested and still have more available; while `resultIncomplete` is absent or `false`, treat a non-null `continuationToken` as the only signal that more results exist. For `ls`, the `result.limitReached` flag reports that same condition and is kept for parity with shell and script output. `ls` always produces resumable pages, so its results never set `resultIncomplete`. Some queries cannot be paged at all. Vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections whose plan cannot export a token execute normally but never return a continuation token. For `DISTINCT` this is decided by the query plan rather than by the SQL text: `SELECT DISTINCT c.category FROM c ORDER BY c.category` is not resumable even though it has an `ORDER BY`, while the scalar `SELECT DISTINCT VALUE c.category FROM c ORDER BY c.category` is. For those queries the server keeps reading until `max` is reached and returns a `null` token. If the limit truncated the results, the response also sets `resultIncomplete` to `true`; a `null` token combined with `resultIncomplete` means the result set was **not** exhausted and cannot be resumed. Retry with a larger `max` or a narrower query instead of sending a `continuation`. From 4b957500ed87158c622953eb964602d47a15449a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 23 Sep 2026 22:19:06 +0200 Subject: [PATCH 5/7] Cover stale token discard and qualify null token documentation --- .../CommandTests/QueryCommandTests.cs | 18 ++++++++++++++++++ docs/commands.md | 2 +- docs/mcp.md | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs index 2edad5a5..cc3708ae 100644 --- a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs @@ -675,6 +675,24 @@ public async Task ExecuteQueryAsync_ObjectShapedDistinctWithOrderBy_ReturnsDocum Assert.False(result.IncompleteWithoutContinuation); } + [Fact] + public async Task ExecuteQueryAsync_TokenExportRefusedOnLaterPage_DiscardsEarlierToken() + { + using var shell = ShellInterpreter.CreateInstance(); + using var iterator = new FakeFeedIterator( + ResumablePage("stale-token", 1, "1"), + NonResumablePage("Continuation tokens are not supported by hybrid search.", 1, "2")); + var container = CreateContainer(iterator); + var command = new QueryCommand { Query = "SELECT c.id FROM c", Max = 10 }; + + var result = await command.ExecuteQueryAsync(container, shell, CancellationToken.None); + + Assert.Equal(["1", "2"], ReadIds(result)); + Assert.Equal(2, iterator.ReadCount); + Assert.Null(result.ContinuationToken); + Assert.False(result.IncompleteWithoutContinuation); + } + [Fact] public async Task ExecuteQueryAsync_ResumablePage_KeepsSingleMcpPageAndToken() { diff --git a/docs/commands.md b/docs/commands.md index 69fc5230..a79e1684 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -392,7 +392,7 @@ Options: `query` does not apply a default item limit. Use `--max ` to cap returned items when needed, or `--max 0` to disable the limit explicitly. -When called through MCP, `query` and container-item `ls` return one page at a time. `max` must be positive; omitted or non-positive values use the safe default of `100` items. The MCP result includes `continuationToken`; pass a non-null value back as the tool's `continuation` argument, with the same query and options, to retrieve the next page. A null token means there are no more pages. `continuation` is an MCP-only argument and is deliberately not a shell option, so interactive and scripted commands are unaffected and retain their existing multi-page behavior. +When called through MCP, `query` and container-item `ls` return one page at a time. `max` must be positive; omitted or non-positive values use the safe default of `100` items. The MCP result includes `continuationToken`; pass a non-null value back as the tool's `continuation` argument, with the same query and options, to retrieve the next page. A null token means there are no more pages, unless the result also sets `resultIncomplete`, which marks a truncated result that cannot be resumed. `continuation` is an MCP-only argument and is deliberately not a shell option, so interactive and scripted commands are unaffected and retain their existing multi-page behavior. Some query shapes execute normally but cannot be resumed: vector `ORDER BY`, `ORDER BY RANK` relevance ranking, and `DISTINCT` projections whose execution plan cannot export a token never produce a continuation token. Whether a `DISTINCT` query is resumable is decided by its plan, not by the presence of an `ORDER BY` clause: `SELECT DISTINCT c.category FROM c ORDER BY c.category` is not resumable, while the scalar `SELECT DISTINCT VALUE c.category FROM c ORDER BY c.category` is. Such queries run to completion within the requested limit instead of failing. Because they cannot be paged, MCP calls keep reading the same query until the limit is reached rather than returning after one page. If the limit truncates the results, the shell reports that they cannot be resumed and the MCP result sets `resultIncomplete` to `true`; raise `--max`, use `--max 0` for no limit, or narrow the query to get the full result set. diff --git a/docs/mcp.md b/docs/mcp.md index 3c65a739..59afd3e7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -127,7 +127,7 @@ Both representations are always byte-for-byte equivalent. | ----- | ------------ | ----------- | | `result` | Commands that produce output | The command result as JSON (objects, arrays, or a scalar). Text-only results are represented as a JSON string. Failed transactional batches include their per-operation summary here alongside `error`. | | `outputText` | CSV output commands with non-empty text | The CSV rendering of the result. Omitted when the CSV output is empty or whitespace. | -| `continuationToken` | Paged `query` and container-item `ls` results | Opaque token for the next page, or `null` when no more results are available. Omitted for `query --explain` and database/container name listings, which are not paged. | +| `continuationToken` | Paged `query` and container-item `ls` results | Opaque token for the next page, or `null` when no more results are available — unless `resultIncomplete` is `true`, where a `null` token accompanies a truncated result that cannot be resumed. Omitted for `query --explain` and database/container name listings, which are not paged. | | `resultIncomplete` | Truncated results that cannot be resumed | `true` when the result stops at the requested limit and the query cannot produce a continuation token. Omitted otherwise. | | `requestCharge` | Charged data-plane command results | The Cosmos DB request charge (in RUs) consumed by the command, as a number. This is omitted for commands that do not issue a billable request. | | `error` | Failed commands | The error message. | From b578bf0f2e17223182ece7a3caed796aeb579810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Fri, 25 Sep 2026 11:55:55 +0200 Subject: [PATCH 6/7] Clarify null continuation token contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs index d2864528..701e3784 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs @@ -75,7 +75,9 @@ public OutputFormat OutputFormat internal bool IsPage { get; set; } /// - /// Gets or sets the token for retrieving the next page, or when the result is exhausted. + /// Gets or sets the token for retrieving the next page. A token + /// indicates exhaustion only when is false; + /// otherwise the result is truncated and cannot be resumed. /// internal string? ContinuationToken { get; set; } From 733dde86971202f75e4c1ad6d3f56eabe4af12a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Fri, 25 Sep 2026 12:33:01 +0200 Subject: [PATCH 7/7] Serialize query tests that replace the global console Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs index cc3708ae..5cdc6e45 100644 --- a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs @@ -14,6 +14,7 @@ namespace CosmosShell.Tests.CommandTests; using NSubstitute; using Spectre.Console; +[Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)] public class QueryCommandTests { private class TestServerSideMetrics : ServerSideMetrics