Files
superset2/tests/unit_tests/mcp_service/utils/test_token_utils.py

855 lines
33 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Unit tests for MCP service token utilities.
"""
from typing import Any, List
from unittest.mock import patch
from pydantic import BaseModel
from superset.mcp_service.utils import token_utils
from superset.mcp_service.utils.token_utils import (
_replace_collections_with_summaries,
_summarize_large_dicts,
_truncate_lists,
_truncate_strings,
_truncate_strings_recursive,
CHARS_PER_TOKEN,
estimate_response_tokens,
estimate_token_count,
extract_query_params,
format_size_limit_error,
generate_size_reduction_suggestions,
get_response_size_bytes,
INFO_TOOLS,
truncate_oversized_response,
truncate_query_result,
)
class TestEstimateTokenCount:
"""Test estimate_token_count function."""
def test_estimate_string(self) -> None:
"""Should produce a positive non-zero estimate for a normal string.
We don't assert on a specific number because the result depends on
which tokenizer is loaded (tiktoken when available, char heuristic
otherwise).
"""
text = "Hello world"
result = estimate_token_count(text)
assert result > 0
def test_estimate_bytes(self) -> None:
"""Bytes input should be decoded and produce the same count as the
equivalent string."""
text = "Hello world"
assert estimate_token_count(text.encode("utf-8")) == estimate_token_count(text)
def test_empty_string(self) -> None:
"""Should return 0 for empty string and empty bytes."""
assert estimate_token_count("") == 0
assert estimate_token_count(b"") == 0
def test_json_like_content(self) -> None:
"""JSON content should produce a positive estimate."""
json_str = '{"name": "test", "value": 123, "items": [1, 2, 3]}'
assert estimate_token_count(json_str) > 0
def test_long_text_roughly_scales_with_length(self) -> None:
"""A doubled string should produce roughly double the token count
(within ±10%)."""
small = "the quick brown fox jumps over the lazy dog. " * 20
large = small * 2
small_n = estimate_token_count(small)
large_n = estimate_token_count(large)
# Within 10% of 2x — both tokenizers (tiktoken and the char
# fallback) preserve length monotonicity.
assert 1.8 * small_n <= large_n <= 2.2 * small_n
def test_fallback_uses_chars_per_token_when_tiktoken_unavailable(
self,
) -> None:
"""When the tiktoken encoding is None (not installed), the
function falls back to len/CHARS_PER_TOKEN math."""
text = "x" * 100
with patch.object(token_utils, "_ENCODING", None):
result = estimate_token_count(text)
assert result == int(100 / CHARS_PER_TOKEN)
def test_fallback_when_tiktoken_encode_raises(self) -> None:
"""A misbehaving encoding should fall back to the char heuristic
rather than raise — the size guard must never fail-open."""
class BoomEncoding:
def encode(self, text: str) -> list[int]:
raise ValueError("simulated tiktoken failure")
text = "abc" * 50
with patch.object(token_utils, "_ENCODING", BoomEncoding()):
result = estimate_token_count(text)
assert result == int(len(text) / CHARS_PER_TOKEN)
class TestEstimateResponseTokens:
"""Test estimate_response_tokens function."""
class MockResponse(BaseModel):
"""Mock Pydantic response model."""
name: str
value: int
items: List[Any]
def test_estimate_pydantic_model(self) -> None:
"""Should estimate tokens for Pydantic model."""
response = self.MockResponse(name="test", value=42, items=[1, 2, 3])
result = estimate_response_tokens(response)
assert result > 0
def test_estimate_dict(self) -> None:
"""Should estimate tokens for dict."""
response = {"name": "test", "value": 42}
result = estimate_response_tokens(response)
assert result > 0
def test_estimate_list(self) -> None:
"""Should estimate tokens for list."""
response = [{"name": "item1"}, {"name": "item2"}]
result = estimate_response_tokens(response)
assert result > 0
def test_estimate_string(self) -> None:
"""Should estimate tokens for string response."""
response = "Hello world"
result = estimate_response_tokens(response)
assert result > 0
def test_estimate_large_response(self) -> None:
"""Should estimate tokens for large response."""
response = {"items": [{"name": f"item{i}"} for i in range(1000)]}
result = estimate_response_tokens(response)
assert result > 1000 # Large response should have many tokens
class TestGetResponseSizeBytes:
"""Test get_response_size_bytes function."""
def test_size_dict(self) -> None:
"""Should return size in bytes for dict."""
response = {"name": "test"}
result = get_response_size_bytes(response)
assert result > 0
def test_size_string(self) -> None:
"""Should return size in bytes for string."""
response = "Hello world"
result = get_response_size_bytes(response)
assert result == len(response.encode("utf-8"))
def test_size_bytes(self) -> None:
"""Should return size for bytes."""
response = b"Hello world"
result = get_response_size_bytes(response)
assert result == len(response)
class TestExtractQueryParams:
"""Test extract_query_params function."""
def test_extract_pagination_params(self) -> None:
"""Should extract pagination parameters."""
params = {"page_size": 100, "limit": 50}
result = extract_query_params(params)
assert result["page_size"] == 100
assert result["limit"] == 50
def test_extract_column_selection(self) -> None:
"""Should extract column selection parameters."""
params = {"select_columns": ["name", "id"]}
result = extract_query_params(params)
assert result["select_columns"] == ["name", "id"]
def test_extract_from_nested_request(self) -> None:
"""Should extract from nested request object."""
params = {"request": {"page_size": 50, "filters": [{"col": "name"}]}}
result = extract_query_params(params)
assert result["page_size"] == 50
assert result["filters"] == [{"col": "name"}]
def test_empty_params(self) -> None:
"""Should return empty dict for empty params."""
assert extract_query_params(None) == {}
assert extract_query_params({}) == {}
def test_extract_filters(self) -> None:
"""Should extract filter parameters."""
params = {"filters": [{"col": "name", "opr": "eq", "value": "test"}]}
result = extract_query_params(params)
assert "filters" in result
class TestGenerateSizeReductionSuggestions:
"""Test generate_size_reduction_suggestions function."""
def test_suggest_reduce_page_size(self) -> None:
"""Should suggest reducing page_size when present."""
params = {"page_size": 100}
suggestions = generate_size_reduction_suggestions(
tool_name="list_charts",
params=params,
estimated_tokens=50000,
token_limit=25000,
)
assert any(
"page_size" in s.lower() or "limit" in s.lower() for s in suggestions
)
def test_suggest_add_limit_for_list_tools(self) -> None:
"""Should suggest adding limit for list tools."""
params: dict[str, Any] = {}
suggestions = generate_size_reduction_suggestions(
tool_name="list_charts",
params=params,
estimated_tokens=50000,
token_limit=25000,
)
assert any(
"limit" in s.lower() or "page_size" in s.lower() for s in suggestions
)
def test_suggest_select_columns(self) -> None:
"""Should suggest using select_columns."""
params: dict[str, Any] = {}
suggestions = generate_size_reduction_suggestions(
tool_name="list_charts",
params=params,
estimated_tokens=50000,
token_limit=25000,
)
assert any(
"select_columns" in s.lower() or "columns" in s.lower() for s in suggestions
)
def test_suggest_filters(self) -> None:
"""Should suggest adding filters."""
params: dict[str, Any] = {}
suggestions = generate_size_reduction_suggestions(
tool_name="list_charts",
params=params,
estimated_tokens=50000,
token_limit=25000,
)
assert any("filter" in s.lower() for s in suggestions)
assert not any("editor" in s.lower() for s in suggestions)
assert any("non-user attributes" in s for s in suggestions)
def test_tool_specific_suggestions_execute_sql(self) -> None:
"""Should provide SQL-specific suggestions for execute_sql."""
suggestions = generate_size_reduction_suggestions(
tool_name="execute_sql",
params={"sql": "SELECT * FROM table"},
estimated_tokens=50000,
token_limit=25000,
)
combined = " ".join(suggestions)
# Should suggest SQL LIMIT clause
assert "LIMIT" in combined
# Should suggest the tool's limit parameter
assert "'limit' parameter" in combined.lower() or "limit=" in combined.lower()
def test_execute_sql_with_limit_param_no_duplicate_suggestion(self) -> None:
"""When limit param is already set, should not suggest adding it again."""
suggestions = generate_size_reduction_suggestions(
tool_name="execute_sql",
params={"sql": "SELECT * FROM table", "limit": 500},
estimated_tokens=50000,
token_limit=25000,
)
combined = " ".join(suggestions)
# Should still suggest SQL LIMIT
assert "LIMIT" in combined
# Should suggest reducing the existing limit (from general suggestion)
assert "500" in combined or "limit" in combined.lower()
def test_tool_specific_suggestions_list_charts(self) -> None:
"""Should provide chart-specific suggestions for list_charts."""
suggestions = generate_size_reduction_suggestions(
tool_name="list_charts",
params={},
estimated_tokens=50000,
token_limit=25000,
)
# Should suggest excluding params or query_context
assert any(
"params" in s.lower() or "query_context" in s.lower() for s in suggestions
)
def test_suggests_search_parameter(self) -> None:
"""Should suggest using search parameter."""
suggestions = generate_size_reduction_suggestions(
tool_name="list_dashboards",
params={},
estimated_tokens=50000,
token_limit=25000,
)
assert any("search" in s.lower() for s in suggestions)
class TestFormatSizeLimitError:
"""Test format_size_limit_error function."""
def test_error_contains_token_counts(self) -> None:
"""Should include token counts in error message."""
error = format_size_limit_error(
tool_name="list_charts",
params={},
estimated_tokens=50000,
token_limit=25000,
)
assert "50,000" in error
assert "25,000" in error
def test_error_contains_tool_name(self) -> None:
"""Should include tool name in error message."""
error = format_size_limit_error(
tool_name="list_charts",
params={},
estimated_tokens=50000,
token_limit=25000,
)
assert "list_charts" in error
def test_error_contains_suggestions(self) -> None:
"""Should include suggestions in error message."""
error = format_size_limit_error(
tool_name="list_charts",
params={"page_size": 100},
estimated_tokens=50000,
token_limit=25000,
)
# Should have numbered suggestions
assert "1." in error
def test_error_contains_reduction_percentage(self) -> None:
"""Should include reduction percentage in error message."""
error = format_size_limit_error(
tool_name="list_charts",
params={},
estimated_tokens=50000,
token_limit=25000,
)
# 50% reduction needed
assert "50%" in error or "Reduction" in error
def test_error_limits_suggestions_to_five(self) -> None:
"""Should limit suggestions to 5."""
error = format_size_limit_error(
tool_name="list_charts",
params={},
estimated_tokens=100000,
token_limit=10000,
)
# Count numbered suggestions (1. through 5.)
suggestion_count = sum(1 for i in range(1, 10) if f"{i}." in error)
assert suggestion_count <= 5
def test_error_message_is_readable(self) -> None:
"""Should produce human-readable error message."""
error = format_size_limit_error(
tool_name="list_charts",
params={"page_size": 100},
estimated_tokens=75000,
token_limit=25000,
)
# Should be multi-line and contain key information
lines = error.split("\n")
assert len(lines) > 5
assert "Response too large" in error
assert "Please modify your query" in error
class TestCalculatedSuggestions:
"""Test that suggestions include calculated values."""
def test_suggested_limit_is_calculated(self) -> None:
"""Should calculate suggested limit based on reduction needed."""
params = {"page_size": 100}
suggestions = generate_size_reduction_suggestions(
tool_name="list_charts",
params=params,
estimated_tokens=50000, # 2x over limit
token_limit=25000,
)
# Find the page_size suggestion
page_size_suggestion = next(
(s for s in suggestions if "page_size" in s.lower()), None
)
assert page_size_suggestion is not None
# Should suggest reducing from 100 to approximately 50
assert "100" in page_size_suggestion
assert (
"50" in page_size_suggestion or "reduction" in page_size_suggestion.lower()
)
def test_reduction_percentage_in_suggestions(self) -> None:
"""Should include reduction percentage in suggestions."""
params = {"page_size": 100}
suggestions = generate_size_reduction_suggestions(
tool_name="list_charts",
params=params,
estimated_tokens=75000, # 3x over limit
token_limit=25000,
)
# Should mention ~66% reduction needed (int truncation of 66.6%)
combined = " ".join(suggestions)
assert "66%" in combined
class TestInfoToolsSet:
"""Test the INFO_TOOLS constant."""
def test_info_tools_contains_expected_tools(self) -> None:
"""Should contain all info tools."""
assert "get_chart_info" in INFO_TOOLS
assert "get_dataset_info" in INFO_TOOLS
assert "get_dashboard_info" in INFO_TOOLS
assert "get_instance_info" in INFO_TOOLS
def test_info_tools_does_not_contain_list_tools(self) -> None:
"""Should not contain list or write tools."""
assert "list_charts" not in INFO_TOOLS
assert "execute_sql" not in INFO_TOOLS
assert "generate_chart" not in INFO_TOOLS
class TestTruncateStrings:
"""Test _truncate_strings helper."""
def test_truncates_long_strings(self) -> None:
"""Should truncate strings exceeding max_chars."""
data: dict[str, Any] = {"description": "x" * 1000, "name": "short"}
notes: list[str] = []
changed = _truncate_strings(data, notes, max_chars=500)
assert changed is True
assert len(data["description"]) < 1000
assert "[truncated from 1000 chars]" in data["description"]
assert data["name"] == "short"
assert len(notes) == 1
def test_does_not_truncate_short_strings(self) -> None:
"""Should not truncate strings within limit."""
data: dict[str, Any] = {"name": "hello", "id": 123}
notes: list[str] = []
changed = _truncate_strings(data, notes, max_chars=500)
assert changed is False
assert data["name"] == "hello"
assert len(notes) == 0
class TestTruncateStringsRecursive:
"""Test _truncate_strings_recursive helper."""
def test_truncates_nested_strings_in_list_items(self) -> None:
"""Should truncate strings inside list items (e.g. charts[i].description)."""
data: dict[str, Any] = {
"id": 1,
"charts": [
{"id": 1, "description": "x" * 1000},
{"id": 2, "description": "short"},
],
}
notes: list[str] = []
changed = _truncate_strings_recursive(data, notes, max_chars=500)
assert changed is True
assert "[truncated" in data["charts"][0]["description"]
assert data["charts"][1]["description"] == "short"
assert len(notes) == 1
assert "charts[0].description" in notes[0]
def test_truncates_nested_strings_in_dicts(self) -> None:
"""Should truncate strings inside nested dicts."""
data: dict[str, Any] = {
"filter_state": {
"dataMask": {"some_filter": "y" * 2000},
},
}
notes: list[str] = []
changed = _truncate_strings_recursive(data, notes, max_chars=500)
assert changed is True
assert "[truncated" in data["filter_state"]["dataMask"]["some_filter"]
def test_respects_depth_limit(self) -> None:
"""Should stop recursing at depth 10."""
# Build a deeply nested structure (15 levels)
data: dict[str, Any] = {"level": "x" * 1000}
current = data
for _ in range(15):
current["nested"] = {"level": "x" * 1000}
current = current["nested"]
notes: list[str] = []
_truncate_strings_recursive(data, notes, max_chars=500)
# Should truncate levels 0-10 but stop before 15
assert len(notes) <= 11
def test_handles_empty_structures(self) -> None:
"""Should handle empty dicts and lists gracefully."""
data: dict[str, Any] = {"items": [], "meta": {}, "name": "ok"}
notes: list[str] = []
changed = _truncate_strings_recursive(data, notes, max_chars=500)
assert changed is False
def test_dashboard_with_many_charts_edge_case(self) -> None:
"""Simulate a dashboard with 30 charts each having long descriptions."""
data: dict[str, Any] = {
"id": 1,
"dashboard_title": "Big Dashboard",
"charts": [
{"id": i, "slice_name": f"Chart {i}", "description": "d" * 2000}
for i in range(30)
],
}
notes: list[str] = []
changed = _truncate_strings_recursive(data, notes, max_chars=500)
assert changed is True
# All 30 chart descriptions should be truncated
assert len(notes) == 30
for chart in data["charts"]:
assert len(chart["description"]) < 2000
assert "[truncated" in chart["description"]
class TestTruncateLists:
"""Test _truncate_lists helper."""
def test_truncates_long_lists(self) -> None:
"""Should truncate lists exceeding max_items without inline markers."""
data: dict[str, Any] = {
"columns": [{"name": f"col_{i}"} for i in range(50)],
"tags": [1, 2],
}
notes: list[str] = []
changed = _truncate_lists(data, notes, max_items=10)
assert changed is True
# Exactly 10 items — no marker appended (preserves type contract)
assert len(data["columns"]) == 10
assert all(isinstance(c, dict) and "name" in c for c in data["columns"])
assert data["tags"] == [1, 2] # Not truncated
assert len(notes) == 1
assert "50" in notes[0]
def test_does_not_truncate_short_lists(self) -> None:
"""Should not truncate lists within limit."""
data: dict[str, Any] = {"items": [1, 2, 3]}
notes: list[str] = []
changed = _truncate_lists(data, notes, max_items=10)
assert changed is False
class TestSummarizeLargeDicts:
"""Test _summarize_large_dicts helper."""
def test_summarizes_large_dicts(self) -> None:
"""Should replace large dicts with key summaries."""
big_dict = {f"key_{i}": f"value_{i}" for i in range(30)}
data: dict[str, Any] = {"form_data": big_dict, "id": 1}
notes: list[str] = []
changed = _summarize_large_dicts(data, notes, max_keys=20)
assert changed is True
assert data["form_data"]["_truncated"] is True
assert "30 keys" in data["form_data"]["_message"]
assert data["id"] == 1
def test_does_not_summarize_small_dicts(self) -> None:
"""Should not summarize dicts within limit."""
data: dict[str, Any] = {"params": {"a": 1, "b": 2}}
notes: list[str] = []
changed = _summarize_large_dicts(data, notes, max_keys=20)
assert changed is False
class TestReplaceCollectionsWithSummaries:
"""Test _replace_collections_with_summaries helper."""
def test_replaces_lists_and_dicts(self) -> None:
"""Should clear non-empty collections to reduce size."""
data: dict[str, Any] = {
"columns": [1, 2, 3],
"params": {"a": 1},
"name": "test",
"empty": [],
}
notes: list[str] = []
changed = _replace_collections_with_summaries(data, notes)
assert changed is True
# Lists become empty lists (preserves type)
assert data["columns"] == []
# Dicts become empty dicts (preserves type)
assert data["params"] == {}
# Scalars unchanged
assert data["name"] == "test"
# Empty collections unchanged
assert data["empty"] == []
assert len(notes) == 2
class TestTruncateOversizedResponse:
"""Test truncate_oversized_response function."""
def test_no_truncation_needed(self) -> None:
"""Should return original data when under limit."""
response = {"id": 1, "name": "test"}
result, was_truncated, notes = truncate_oversized_response(response, 10000)
assert was_truncated is False
assert notes == []
def test_truncates_large_string_fields(self) -> None:
"""Should truncate long strings to fit."""
response = {
"id": 1,
"description": "x" * 50000, # Very large description
}
result, was_truncated, notes = truncate_oversized_response(response, 500)
assert was_truncated is True
assert isinstance(result, dict)
assert "[truncated" in result["description"]
assert any("description" in n for n in notes)
def test_truncates_large_lists(self) -> None:
"""Should truncate lists when strings alone are not enough."""
response = {
"id": 1,
"columns": [{"name": f"col_{i}", "type": "VARCHAR"} for i in range(200)],
}
result, was_truncated, notes = truncate_oversized_response(response, 500)
assert was_truncated is True
assert isinstance(result, dict)
# Should have been truncated
assert len(result["columns"]) < 200
def test_handles_pydantic_model(self) -> None:
"""Should handle Pydantic model input."""
class FakeInfo(BaseModel):
id: int = 1
description: str = "x" * 5000
response = FakeInfo()
result, was_truncated, notes = truncate_oversized_response(response, 200)
assert was_truncated is True
assert isinstance(result, dict)
def test_returns_non_dict_unchanged(self) -> None:
"""Should return non-dict/model responses unchanged."""
result, was_truncated, notes = truncate_oversized_response("just a string", 100)
assert was_truncated is False
assert result == "just a string"
def test_progressive_truncation(self) -> None:
"""Should progressively apply truncation phases."""
# Build a response that's quite large
response = {
"id": 1,
"description": "x" * 2000,
"css": "y" * 2000,
"columns": [{"name": f"col_{i}"} for i in range(100)],
"form_data": {f"key_{i}": f"val_{i}" for i in range(50)},
}
result, was_truncated, notes = truncate_oversized_response(response, 300)
assert was_truncated is True
assert isinstance(result, dict)
assert result["id"] == 1 # Scalar fields preserved
assert len(notes) > 0
@staticmethod
def _build_large_dashboard_response() -> dict[str, Any]:
"""A dashboard with 463 charts and 48 native_filters, shared by the
default- and custom-max_list_items regression tests below."""
return {
"id": 1,
"dashboard_title": "x" * 2000, # forces Phase 2 to trigger
"charts": [{"id": i, "slice_name": f"chart_{i}"} for i in range(463)],
"native_filters": [{"id": i, "name": f"filter_{i}"} for i in range(48)],
}
def test_large_dashboard_respects_default_max_list_items(self) -> None:
"""Regression test for the Medialab large-dashboard report.
A dashboard with 463 charts and 48 native_filters should have
native_filters (48 items) left untouched under the new default cap
of 100, while charts (463 items) is truncated to 100 — a clear
improvement over the old flat 30-item cap, which truncated both.
"""
response: dict[str, Any] = self._build_large_dashboard_response()
result: Any
was_truncated: bool
notes: list[str]
result, was_truncated, notes = truncate_oversized_response(response, 3000)
assert was_truncated is True
assert isinstance(result, dict)
assert len(result["charts"]) == 100
assert len(result["native_filters"]) == 48
assert any("charts" in n and "463" in n for n in notes)
assert not any("native_filters" in n for n in notes)
def test_large_dashboard_respects_custom_max_list_items(self) -> None:
"""A custom max_list_items below both list sizes should truncate both fields."""
response: dict[str, Any] = self._build_large_dashboard_response()
result: Any
was_truncated: bool
notes: list[str]
result, was_truncated, notes = truncate_oversized_response(
response, 3000, max_list_items=30
)
assert was_truncated is True
assert isinstance(result, dict)
assert len(result["charts"]) == 30
assert len(result["native_filters"]) == 30
assert any("charts" in n and "30" in n for n in notes)
assert any("native_filters" in n and "30" in n for n in notes)
def test_custom_max_list_items_below_phase_four_survives_phase_four(self) -> None:
"""A max_list_items below Phase 4's hardcoded 10 should not be widened.
Phase 2 truncates ``charts`` to 5 first; the response is still over
budget because of the oversized ``form_data`` dict, so truncation
proceeds to Phase 4, whose ``_truncate_lists(..., max_items=10)``
call only shrinks lists larger than 10 — it must leave the
already-smaller 5-item list untouched rather than re-expanding it.
"""
response: dict[str, Any] = {
"id": 1,
"charts": [{"id": i, "slice_name": f"chart_{i}"} for i in range(300)],
"form_data": {f"key_{i}": f"val_{i}" for i in range(50)},
}
result: Any
was_truncated: bool
notes: list[str]
result, was_truncated, notes = truncate_oversized_response(
response, 200, max_list_items=5
)
assert was_truncated is True
assert isinstance(result, dict)
assert len(result["charts"]) == 5
assert any("form_data" in n for n in notes)
class TestTruncateQueryResult:
"""Tests for ``truncate_query_result`` (data-query row/scalar truncation)."""
def _rows_response(self, row_field: str, count: int = 200) -> dict[str, Any]:
row = {f"col_{i}": f"value_{i}" for i in range(10)}
return {
"status": "success",
row_field: [row] * count,
"row_count": count,
}
def test_no_truncation_needed(self) -> None:
response = self._rows_response("rows", count=3)
result, was_truncated, notes = truncate_query_result(response, 25000)
assert was_truncated is False
assert notes == []
assert result == response
def test_truncated_result_fits_under_limit(self) -> None:
"""The final payload (rows + note metadata) must itself fit.
Regression test: the note is built from the kept row count, but
that note text also consumes tokens. The bisection must reserve
room for it up front rather than measuring fit on bare rows and
appending the note afterward, which could push the final payload
back over the limit.
"""
response = self._rows_response("rows")
result, was_truncated, notes = truncate_query_result(response, 500)
assert was_truncated is True
assert isinstance(result, dict)
assert estimate_response_tokens(result) <= 500
assert result["row_count"] == len(result["rows"])
assert result["row_count"] < 200
def test_single_oversized_row_is_kept_anyway(self) -> None:
"""A single row that alone exceeds the limit is still returned.
``truncate_query_result`` always keeps >=1 row when the original
list is non-empty; it is the caller's (middleware) job to reject
a still-oversized result rather than ship it silently.
"""
response = {
"status": "success",
"rows": [{"col": "x" * 5000}] * 3,
"row_count": 3,
}
result, was_truncated, notes = truncate_query_result(response, 50)
assert was_truncated is True
assert isinstance(result, dict)
assert len(result["rows"]) == 1
assert notes
def test_truncates_csv_scalar_field_when_rows_empty(self) -> None:
"""CSV exports carry their payload in ``csv_data`` with ``data=[]``."""
response: dict[str, Any] = {
"chart_id": 1,
"data": [],
"csv_data": "col_0,col_1\n" + ("value,value\n" * 2000),
"format": "csv",
}
result, was_truncated, notes = truncate_query_result(
response, 500, tool_name="get_chart_data"
)
assert was_truncated is True
assert isinstance(result, dict)
assert len(result["csv_data"]) < len(response["csv_data"])
assert estimate_response_tokens(result) <= 500
assert any("CSV" in n for n in notes)
def test_does_not_truncate_excel_binary_field(self) -> None:
"""excel_data is base64 binary — truncating it would corrupt the file."""
response = {
"chart_id": 1,
"data": [],
"excel_data": "QUJDREVGRw==" * 5000,
"format": "excel",
}
result, was_truncated, notes = truncate_query_result(response, 500)
assert was_truncated is False
assert notes == []
assert isinstance(result, dict)
assert result["excel_data"] == response["excel_data"]
def test_get_chart_data_advice_mentions_limit_param(self) -> None:
response = self._rows_response("data")
_, _, notes = truncate_query_result(response, 500, tool_name="get_chart_data")
assert any("'limit' parameter" in n for n in notes)
assert not any("LIMIT clause" in n for n in notes)
def test_query_dataset_advice_mentions_row_limit_param(self) -> None:
response = self._rows_response("data")
_, _, notes = truncate_query_result(response, 500, tool_name="query_dataset")
assert any("'row_limit' parameter" in n for n in notes)
assert not any("LIMIT clause" in n for n in notes)
def test_execute_sql_advice_mentions_limit_clause(self) -> None:
response = self._rows_response("rows")
_, _, notes = truncate_query_result(response, 500, tool_name="execute_sql")
assert any("LIMIT clause" in n for n in notes)