Skip to content

Read Tools — QA Test Cases

Source: src/mcp_gee_sweet/tools/read.py

Fixtures: see docs/qa/setup.md. Substitute your {SPREADSHEET_ID} from fixtures.local.md.


get_sheet_data

TC-R01: Happy path — fetch all data

Prompt

"Show me all the data in the Sales sheet of {SPREADSHEET_ID}"

Checks - Returns 6 rows (header + 5 data rows) - All 4 columns present: Product, Q1, Q2, Q3 - Row 6 Totals values are computed (650, 670, 705) — not formula strings - No error field

Result (2026-09-04) ✅ PASS 6 rows, 4 cols; Totals computed 650/670/705, not formula strings; no error

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.

Result (2026-10-19) ✅ PASS — v0.9.3 release pass kai-sa: get_sheet_data(Sales) returned 6 rows × 4 columns (Product/Q1/Q2/Q3). The Totals row came back computed as 650/670/705, not as formula strings. No error.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account) and kai-oauth (OAuth), each on its own copy of the fixture spreadsheet: Sales header plus data rows, Totals computed as 650/670/705, no error.


TC-R02: Explicit range

Prompt

"Get the range A1:C3 from the Sales sheet of {SPREADSHEET_ID}"

Checks - Returns exactly 3 rows, 3 columns - Row 1: Product, Q1, Q2 - Row 2: Widget, 100, 120 - Row 3: Gadget, 200, 180

Result (2026-09-04) ✅ PASS A1:C3 → 3×3, Product/Q1/Q2, Widget/100/120, Gadget/200/180

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R03: Grid data with an explicit range

Prompt

"Get A1:D6 from the Sales sheet of {SPREADSHEET_ID} with full grid data including formatting"

Checks - Response includes rowData field - include_grid_data=True was passed to the API (visible in raw response structure) - Call includes range="A1:D6" — no auto-detection probe request happens when a range is given

Result (2026-09-04) ✅ PASS rowData present; range A1:D6 honored; no auto-detect probe (range given)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R03b: Grid data without a range auto-detects the used range (issue #235)

Setup Call get_sheet_data(spreadsheet_id={SPREADSHEET_ID}, sheet="Sales", include_grid_data=True) — no range argument. The Sales sheet has 6 rows x 4 columns of actual content but Sheets' default padded grid is 1000x26.

Checks - No error — the call succeeds - Server makes a values-only probe request first, then a grid-data request scoped to A1:D6 — not the full padded grid - Response includes rowData only for the 6x4 used range, not ~26,000 padded cells - No "Connection closed" / oversized-response symptom (the original failure mode)

Result (2026-07-02) ✅ PASS Called live against the actual Sales fixture (gridProperties: rowCount=3016, columnCount=33). Response's rowData covered exactly the 6x4 used range (header row + Widget/Gadget/Donut/Gizmo + Totals row, matching TC-R01's known fixture content) — confirmed scoped down from the sheet's real 3016x33 padding, not just the smaller 1000x26 default. No error, no truncation.

Result (2026-09-04) ✅ PASS No range + grid data: rowData scoped to used 6×4 range only (grid is 1000×26); no error/truncation

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R03c: Densely formatted range over the safety cap raises a clear error (issue #235)

Setup Apply formatting across a range with format_cells(spreadsheet_id={SPREADSHEET_ID}, sheet="Sales", range="A1:Z200", bold=True, background_color={"red": 0.9, "green": 0.95, "blue": 1}, number_format_type="NUMBER"), then call get_sheet_data(spreadsheet_id={SPREADSHEET_ID}, sheet="Sales", range="A1:Z200", include_grid_data=True).

Note: cell count alone doesn't predict this — a live test found a 26,000-cell blank range costs almost nothing (Sheets omits rowData for untouched cells), while densely-formatted ranges cost ~700-780 bytes/cell. The cap check runs on the actual serialized response, after the fetch, not an estimate from range size. A live binary search against Claude Code's default MCP client found the real failure point around 48,000-51,000 characters — consistent with Claude Code's documented 25,000-token default per-tool-response cap (MAX_MCP_OUTPUT_TOKENS) at ~2 chars/token for this kind of repeated-key JSON. The server-side default (MAX_TOOL_RESPONSE_CHARS=40000, named MAX_GRID_DATA_RESPONSE_CHARS at the time this test was first run — renamed and generalized to 5 more tools by issue #242) sits below that with margin.

Checks - The Sheets API fetch itself succeeds (it's fast either way) — the error comes from the size check on the result, not a failed API call - Error message states the actual response size in characters, the cap, and mentions narrowing the range, local_path, or MAX_TOOL_RESPONSE_CHARS as options - Clean up: remove the formatting from A1:Z200 afterward so it doesn't affect other test cases

Result (2026-07-02) ✅ PASS Tested against a scratch sheet (temp tab, deleted after) rather than formatting the shared Sales fixture directly, to avoid leaving unwanted formatting on it — same effective setup (format_cells(range="A1:Z1000", bold=True, background_color=..., number_format_type="NUMBER"), then get_sheet_data(range="A1:Z200", include_grid_data=True)). Raised: get_sheet_data(include_grid_data=True): the response is 4325100 characters, over the 40000-character safety cap. ... Narrow the range; pass local_path to write the result to disk instead of returning it inline (bypasses this cap); or set MAX_GRID_DATA_RESPONSE_CHARS if your MCP client can handle larger responses (e.g. a raised MAX_MCP_OUTPUT_TOKENS). Exact size (4,325,100 chars) confirms the fetch completed before the check ran, as designed.

Result (2026-09-04) ✅ PASS Fetch succeeded; size check raised ValueError naming actual size 4,085,918 chars, cap 1,000,000, and narrow-range/local_path/MAX_TOOL_RESPONSE_CHARS options. Cap default is now 1,000,000 (#519); Setup text in test doc citing 40,000 is stale, behavior correct

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R03d: Grid data with local_path writes to disk instead of returning inline (issue #235)

Setup Same formatted range as TC-R03c, but call get_sheet_data(spreadsheet_id={SPREADSHEET_ID}, sheet="Sales", range="A1:Z200", include_grid_data=True, local_path="/tmp/qa_grid_data.json").

Checks - Call succeeds (no cap error) despite the response exceeding the cap - Response is a small dict — local_path, spreadsheet_id, sheet, range, bytes_written — not the grid data itself - /tmp/qa_grid_data.json exists and contains the full grid-data JSON (rowData present, formatting visible)

Result (2026-07-02) ✅ PASS Same scratch-sheet setup as TC-R03c. Call returned {"local_path": "/tmp/qa_grid_data_235.json", "spreadsheet_id": "...", "sheet": "SizeTest235d", "range": "SizeTest235d!A1:Z200", "bytes_written": 4325100} — no error despite exceeding the cap. Verified on disk: file exists, wc -c matches bytes_written exactly (4,325,100), and contains real rowData with formatting. Scratch sheet and temp file both cleaned up afterward.

Result (2026-09-04) ✅ PASS local_path set → returned {local_path, bytes_written 4,085,918, spreadsheet_id, sheet, range}; file on disk has rowData + formatting

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R03e: MAX_TOOL_RESPONSE_CHARS raises the cap (issue #235)

Setup Set MAX_TOOL_RESPONSE_CHARS=200000 in server config and restart the server (e.g. to match a MAX_MCP_OUTPUT_TOKENS raised in your MCP client config). Repeat the same call as TC-R03c: a formatted range whose response is over the default 40,000-character cap but under 200,000.

Checks - Call now succeeds instead of raising — confirms the cap is actually read from the env var, not hardcoded - Restore MAX_TOOL_RESPONSE_CHARS (unset it) after this test

Result: ⏳ Pending — not yet live-tested. Unlike TC-R03b-d, this requires a server restart with a changed env var (not just an MCP reconnect), which wasn't done this pass. Covered by unit tests (test_cap_is_configurable in tests/sheets/test_data.py, test_env_var_sets_cap_at_import_time in tests/test_response_limits.py) in the meantime. Var renamed from MAX_GRID_DATA_RESPONSE_CHARS by issue #242 (generalized to 5 more tools). Note: since issue #519 raised the shipped default to 1,000,000, demonstrating configurability now requires setting MAX_TOOL_RESPONSE_CHARS below that default (e.g. 200000 as originally written still works) against a range sized to land between the custom cap and whatever the range would otherwise produce uncapped — the "over the default 40,000-character cap" framing in the Setup above is stale (pre-#519); the demonstration itself (env var actually changes behavior) doesn't depend on the specific default value.

Result (2026-09-04) ⏭️ SKIP Requires server restart with changed MAX_TOOL_RESPONSE_CHARS env var — not possible this pass (pre-existing pending)

Result (2026-10-17) ⏭️ SKIP — v0.9.2 release pass Needs a server restart with a changed MAX_TOOL_RESPONSE_CHARS.


TC-R38: Grid data over the old cap now succeeds under the raised default (issue #519)

Background: Issue #519 raised MAX_TOOL_RESPONSE_CHARS's default from 40,000 to 1,000,000 after live-testing found the client-connection-death failure mode the cap defends against no longer reproduces at that scale for the primary MCP client (see docs/decisions/decision-response-size-cap-reevaluation-519.md). This test confirms a range that used to trip the old default now succeeds without needing local_path or a MAX_TOOL_RESPONSE_CHARS override.

Setup Apply the same kind of formatting as TC-R03c but to a smaller range — start with format_cells(spreadsheet_id={SPREADSHEET_ID}, sheet="Sales", range="A1:Z23", bold=True, background_color={"red": 0.9, "green": 0.95, "blue": 1}, number_format_type="NUMBER") (598 cells; TC-R03c's ~832 bytes/cell rate would put this around ~498,000 chars). Use a scratch sheet, not the shared Sales fixture, to avoid leaving formatting behind. Then call get_sheet_data(spreadsheet_id={SPREADSHEET_ID}, sheet=<scratch sheet>, range="A1:Z23", include_grid_data=True) with no local_path and no MAX_TOOL_RESPONSE_CHARS override (default server config).

If the measured response lands outside the 40,000–1,000,000 band (formatting density varies), adjust the range size and re-measure — the point is landing strictly between the old and new default, not this exact range.

Checks - Call succeeds — no ValueError raised - Response contains real grid data (rowData present, formatting visible) — not a manifest/pointer - Response size (measure via the returned JSON) is confirmed to be over 40,000 characters (would have tripped the old default) and under 1,000,000 (the new default) - Clean up: delete the scratch sheet afterward

Result: ✅ PASS — live-tested against mcp-gee-sweet-qa-fixtures (scratch sheet QA-TC-R38-Scratch, deleted after). get_sheet_data(..., include_grid_data=True) on the formatted A1:Z23 range succeeded with no ValueError; response was 428,753 characters (Claude Code's own client-side MCP output cap wrote it to a local file, unrelated to the server's MAX_TOOL_RESPONSE_CHARS cap, which was never tripped) — comfortably between the old 40,000 default and the new 1,000,000 default. Confirmed rowData present with real formatting (userEnteredFormat/effectiveFormat showing bold, background color, number format) across all 23 rows, not a manifest/pointer.

Result (2026-09-04) ✅ PASS Formatted A1:Z23 on scratch sheet; get_sheet_data(grid) returned 428,765-char response with NO server ValueError (between old 40k and new 1M default). Claude Code client-side cap wrote it to a file; rowData + bold:true confirmed present

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R04: Non-existent sheet name

Background: The 2026-09-04 result below found this call leaked a raw HttpError instead of the {"error": ...} shape every other validation failure in these tools returns — this is get_sheet_data's own instance of the broader gap tracked as issue #757 (no local validation on the raw range string handed to the Sheets values API). Fixed by wrapping this call site (and its include_grid_data=True sibling — see TC-R41) in try/except HttpError. The Checks below describe the post-fix expected behavior; the dated Result above predates the fix.

Prompt

"Get data from a sheet called 'DoesNotExist' in {SPREADSHEET_ID}"

Checks - Returns {"error": "<HttpError text, e.g. ...Unable to parse range: DoesNotExist...>"} — a clean {"error": ...} dict, not a raw exception propagating to the client - Error message references the sheet name or indicates it was not found

Result (2026-09-04) ✅ PASS Nonexistent sheet → HttpError 400 "Unable to parse range: DoesNotExist" — clear error, names the sheet, not empty result (raw HttpError, not friendly {"error"})

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R05: Non-existent spreadsheet ID

Background: Exercises the same get_sheet_data non-grid call site as TC-R04 (values().get(), issue #757), via an invalid spreadsheet_id instead of an invalid sheet name — same defect, same fix (try/except HttpError, now _execute_or_error). The dated Result below predates the fix and shows the pre-fix raw-HttpError behavior; post-fix, this returns {"error": "<HttpError 404 ... Requested entity was not found ...>"} instead.

Prompt

"Get data from the Sales sheet of spreadsheet 'invalidid123xyz'"

Checks - Returns a clear API error - Does not crash the server or return empty data silently

Result (2026-09-04) ✅ PASS Invalid spreadsheet ID → HttpError 404 "Requested entity was not found." — no crash, no silent empty

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R06: Range beyond data bounds

Prompt

"Get data from range A100:Z200 in the Sales sheet of {SPREADSHEET_ID}"

Checks - Returns empty values, not an error - No error field — the API accepts out-of-bounds ranges gracefully

Result (2026-09-04) ✅ PASS A100:Z200 → values: [], no error

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R07: Sheet name with spaces and special characters

Prompt

"Show me all data from the 'Notes & Misc' sheet in {SPREADSHEET_ID}"

Checks - Returns 2 rows: header (Date, Note) and data row - Sheet name with spaces and & resolved correctly - Date cell shows today's date (computed from =TODAY())

Result (2026-09-04) ✅ PASS 'Notes & Misc' resolved (spaces + &); 2 rows; date cell computed

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R41: Malformed range with include_grid_data=True returns a clean error (issue #757)

Background: TC-R04 covers this same defect class on get_sheet_data's non-grid (values().get()) call site. The include_grid_data=True path uses a separate call (spreadsheets().get(..., includeGridData=True)) and had the identical gap — no local validation on the raw caller-supplied range string, so a malformed one reached the client as an uncaught HttpError. Fixed in the same PR, same try/except HttpError pattern.

Prompt

"Get data from the Sales sheet of {SPREADSHEET_ID}, range '!!!BadRange!!!', with include_grid_data=True"

Checks - Returns {"error": "<HttpError text>"} — not a raw exception - No response-size-cap check runs (the call fails before the grid data is ever fetched)

Cleanup: none — no write occurred.

Result (2026-09-18) ✅ PASS — get_sheet_data(spreadsheet_id, sheet="Sales", range="!!!BadRange!!!", include_grid_data=True) returned {"error": "<HttpError 400 ... Unable to parse range: Sales!!!!BadRange!!! ...>"}, not a raw exception.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R43: Malformed sheet name during auto-detection returns a clean error (issue #757)

Background: QA round 1 on PR #764 found a third, earlier call site the first pass missed: include_grid_data=True with no range triggers an auto-detect-used-range probe (values().get(), issue #235) before either TC-R04's or TC-R41's own call ever runs. A bad sheet name reaches this probe call raw, same gap, same fix.

Prompt

"Get data from a sheet called 'DoesNotExist' in {SPREADSHEET_ID}, with include_grid_data=True and no range"

Checks - Returns {"error": "<HttpError text>"} — not a raw exception - The spreadsheets().get(..., includeGridData=True) call is never made (the probe fails first)

Cleanup: none — no write occurred.

Result (2026-09-18) ✅ PASS — get_sheet_data(spreadsheet_id, sheet="DoesNotExist", include_grid_data=True) against the fixture spreadsheet returned {"error": "<HttpError 400 ... Unable to parse range: DoesNotExist ...>"}, not a raw exception.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


get_sheet_formulas

TC-R08: Sheet with formulas — returns formula strings

Prompt

"Show me the formulas in the Sales sheet of {SPREADSHEET_ID}"

Checks - Row 6 B–D cells show formula strings: =SUM(B2:B5), =SUM(C2:C5), =SUM(D2:D5) - Data rows return literal values (100, 200, etc.), not formula strings

Result (2026-09-04) ✅ PASS Row 6 → =SUM(B2:B5)/=SUM(C2:C5)/=SUM(D2:D5); data rows literal ints

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.

Result (2026-10-19) ✅ PASS — v0.9.3 release pass kai-sa: get_sheet_formulas(Sales) returned row 6 as =SUM(B2:B5)/=SUM(C2:C5)/=SUM(D2:D5). Data rows came back as literal ints (100, 200, …).

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account) and kai-oauth (OAuth): row 6 returned as =SUM(B2:B5)/=SUM(C2:C5)/=SUM(D2:D5), data rows as literals.


TC-R09: Sheet with no formulas

Prompt

"Show me the formulas in the Empty sheet of {SPREADSHEET_ID}"

Checks - Returns empty result or empty values — not an error

Result (2026-09-04) ✅ PASS Empty sheet formulas → []

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R10: Mixed cells — formulas and literals

Prompt

"Get formulas from the Notes & Misc sheet of {SPREADSHEET_ID}"

Checks - A2 returns =TODAY() (formula string) — Notes & Misc is laid out Date (col A) / Note (col B) - B2 ("Setup complete") returns a literal string, not a formula - No cell returns a computed value where a formula exists

Result (2026-09-04) ✅ PASS A2 → "=TODAY()"; B2 → "Setup complete" literal

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R11: No range provided — fetches entire sheet

Prompt

"Get all formulas from the Sales sheet of {SPREADSHEET_ID} — no range filter"

Checks - All 6 rows returned - Formula cells in row 6 show formula strings - Equivalent to TC-R08 — confirms default behavior with no range arg

Result (2026-09-04) ✅ PASS No range → all 6 rows, row 6 formula strings; matches TC-R08

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R42: Malformed range returns a clean error, not a raw exception (issue #757)

Background: get_sheet_formulas had the same gap as TC-R04/TC-R41 — no local validation on the raw caller-supplied range string before handing it to values().get(..., valueRenderOption="FORMULA").

Prompt

"Get formulas from the Sales sheet of {SPREADSHEET_ID}, range '!!!BadRange!!!'"

Checks - Returns {"error": "<HttpError text>"} — not a raw exception propagating to the client

Cleanup: none — no write occurred.

Result (2026-09-18) ✅ PASS — get_sheet_formulas(spreadsheet_id, sheet="Sales", range="!!!BadRange!!!") returned {"error": "<HttpError 400 ... Unable to parse range: Sales!!!!BadRange!!! ...>"}, not a raw exception.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


get_multiple_sheet_data

TC-R12: Multiple valid queries

Prompt

"Get data from two sheets at once: the Sales sheet and the Notes & Misc sheet, both from {SPREADSHEET_ID}"

Checks - Returns two results, one per sheet - Each result has the correct data for its sheet - No error field on either result

Result (2026-09-04) ✅ PASS Two results, correct data per sheet, no errors

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R13: One query with missing required keys

Prompt

"Fetch multiple sheets: first the Sales sheet from {SPREADSHEET_ID}, second a query with no sheet name specified"

Checks - Sales sheet result succeeds - Invalid query returns an error field — does not crash the other result

Result (2026-09-04) ✅ PASS Sales ok; 2nd query (no sheet) → error "Missing required keys (spreadsheet_id, sheet)"; first unaffected

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R14: All queries fail

Prompt

"Fetch data from two sheets: 'FakeSheet1' and 'FakeSheet2', both from {SPREADSHEET_ID}"

Checks - Both results have error fields - Response is a list of two error objects — not a top-level error

Result (2026-09-04) ✅ PASS Both entries have error fields; list of 2 error objects, not top-level error

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R15: Empty queries list

Prompt

"Fetch multiple sheets from {SPREADSHEET_ID} — pass an empty list of queries"

Checks - Returns [] — empty list, not an error

Result (2026-09-04) ✅ PASS []

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R36: 5 concurrent queries — each result attributed to the correct query (issue #183)

Background: #183 made get_multiple_sheet_data fetch all queries concurrently via asyncio.gather() instead of one at a time. gather() is documented to preserve result order regardless of completion order, but this can only be verified against the real API, not mocks. Uses 5 distinct, easily-confused ranges from the same sheet so a mixed-up result is obvious.

Setup No fixture setup needed — query 5 different single-cell ranges from the Sales sheet in one call (e.g. A1, B1, C1, A2, B2 — cells with known, distinct values from the existing fixture).

Prompt

"In one call, get these 5 ranges from the Sales sheet in {SPREADSHEET_ID}: A1, B1, C1, A2, B2"

Checks - Returns 5 results in the same order as the 5 queries were given - Each result's data matches the actual cell content at that range, not another range's content - No error field on any result

Result (2026-07-12) ❌ FAIL Against the OAuth server (mcp-gee-sweet-sky), ran twice back-to-back with the exact 5-range prompt. Both runs returned 3 of 5 results as connection-level errors instead of data — run 1: B1 → [SSL] record layer failure (_ssl.c:2658), A2 → Remote end closed connection without response, B2 → [SSL] record layer failure (_ssl.c:2658); run 2 (different ranges failed, confirming it's not one bad range): A1 → [Errno 54] Connection reset by peer, C1 → Remote end closed connection without response, A2 → Remote end closed connection without response. Result order was preserved for the entries that succeeded, but this reliably reproduces the concurrency bug identified in code review: auth.thread_http() (src/mcp_gee_sweet/auth.py:66) is invoked as an eagerly-evaluated kwarg to asyncio.to_thread(...), so it resolves on the event-loop thread rather than the intended worker thread — every concurrently-gathered call ends up sharing one httplib2.Http/SSL transport across N real worker threads, which is not safe for concurrent use and produces exactly this class of intermittent connection/SSL corruption. This is the core mechanism the PR's own new QA case TC-I24 was written to catch. Sends back to Dev — not a QA-environment flake, reproduced twice with different specific failures each time.

Dev note (2026-07-13): Fixed — added execute_in_thread() (src/mcp_gee_sweet/http_transport.py) which defers the thread_http(service) call into the lambda that asyncio.to_thread() actually runs on the worker thread, instead of resolving it eagerly on the event-loop thread. Applied across all 141 affected call sites plus one non-standard site in export_revision. Unit suite green (674 tests); live re-verification of this exact test case still needed — not marking a Result here since it hasn't been re-run live.

Result (2026-07-13) ✅ PASS (re-verified after fix) Against the OAuth server (mcp-gee-sweet-sky), ran the identical 5-range prompt 3 times back-to-back after reconnecting to pick up the fix commit (18490d8). All 3 runs returned all 5 results with correct data and zero errors — A1→Product, B1→Q1, C1→Q2, A2→Widget, B2→100, matching the fixture exactly, in the requested order, every time. Previously reproduced 3/5 connection errors on 2/2 runs before the fix; now clean on 3/3 runs after. Confirms the execute_in_thread() fix resolves the live concurrency bug, not just the unit-test suite.

Release-pass note (#673): this is within-call concurrency (get_multiple_sheet_data gather()s the 5 .execute() calls inside one tool call) — a single session exercises it, no barrier procedure needed. The v0.9.0 Full Regression pass re-runs this normally against the release-candidate server (mcp-gee-sweet-kai-sa) and records a fresh dated Result, superseding the 2026-07-12 ❌ FAIL above (kept only as history of the execute_in_thread bug).

Result (2026-09-04) ✅ PASS 5 ranges A1/B1/C1/A2/B2 → 5 results in order, each correct (Product/Q1/Q2/Widget/100), zero errors. Fresh pass supersedes 2026-07-12 FAIL

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Three 5-range orderings returned cell-exact, in-order results.


TC-R39: get_multiple_sheet_data emits notifications/progress updates as each query completes (issue #355)

Background: #355 extends #316/#319's per-item ctx.report_progress() pattern (originally added to download_folder/sync_folder — see TC-D199) to get_multiple_sheet_data's concurrent per-query fetches: ctx.report_progress(completed, total, f"{spreadsheet_id}/{sheet}: {outcome}") fires from inside each query's own coroutine as it finishes, wrapped in try/except so a broken notification channel can't demote an already-successful fetch. The exact per-query call count, total, and message content are asserted against a mocked ctx in tests/sheets/test_data.py::TestGetMultipleSheetData::test_reports_progress_per_query/test_report_progress_failure_does_not_demote_a_successful_fetch.

Note: Same protocol-level caveat as TC-D199 — notifications/progress isn't part of the tool's JSON response, so visibility depends on whether this QA client sets a progressToken. If it doesn't, this check can only confirm the call still completes normally with the new ctx.report_progress(...) call path in place.

Prompt

"In one call, get these 2 ranges from {SPREADSHEET_ID}: Sales!A1:B2 and Notes & Misc!A1"

Checks - Call completes normally, returns 2 results, no error - If progress notifications are visible in the client: 2 discrete updates appear, one per query, not a single update at the very end

Teardown None.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Correct results; progress notifications aren't visible in this client.


get_multiple_spreadsheet_summary

TC-R16: Happy path — multiple spreadsheet IDs

Prompt

"Give me a summary of {SPREADSHEET_ID} — just a quick overview of its sheets and first few rows"

Checks - Returns entries for all 3 sheets: Sales, Empty, Notes & Misc - Sales entry includes headers (Product, Q1, Q2, Q3) and first data rows - Empty sheet entry has empty headers and empty first_rows

Result (2026-09-04) ✅ PASS All 3 sheets; Sales headers + first rows; Empty headers [] / first_rows []

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.

Result (2026-10-19) ✅ PASS — v0.9.3 release pass kai-sa: get_multiple_spreadsheet_summary([fixture]) returned all 3 sheets. Sales had headers Product/Q1/Q2/Q3 plus 4 first rows; Empty had headers [] and first_rows [].

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account) and kai-oauth (OAuth): all 3 sheets summarized (Sales headers Product/Q1/Q2/Q3; Notes & Misc Date/Note; Empty []).


TC-R37: Concurrent summary across multiple distinct spreadsheets — no cross-attribution ⚠️ requires-oauth (issue #183)

Background: TC-R16 exercises the inner per-sheet loop within one spreadsheet, which stayed sequential in #183 — it does not exercise the outer per-spreadsheet loop, which is the part that actually became concurrent via asyncio.gather(). This test specifically targets that outer loop with multiple distinct spreadsheet IDs.

Setup Create 2 additional throwaway spreadsheets (QA-Summary-183-B, QA-Summary-183-C), each with a distinct, identifiable title and a Sheet1 containing distinct header text (e.g. "marker-B" / "marker-C" in cell A1).

Prompt

"Give me summaries of these 3 spreadsheets in one call: {SPREADSHEET_ID}, {the QA-Summary-183-B id}, {the QA-Summary-183-C id}"

Checks - Returns 3 entries, one per spreadsheet, in the same order as requested - Each entry's title and sheet contents match that spreadsheet — not another one's (would indicate cross-attribution under concurrency) - No error field on any entry

Teardown Delete the 2 throwaway spreadsheets.

Result (2026-09-04) ✅ PASS 3 distinct spreadsheets in one call → 3 entries in order, each title/contents match its own (marker-B / marker-C), no cross-attribution, no errors. Throwaway sheets deleted

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R17: Cache hit — second call skips API

Prompt (run twice in the same session)

"Summarize {SPREADSHEET_ID} again"

Checks - Second call returns same data - Server logs show cache hit for the second call (check make logs)

Result (2026-09-04) ✅ PASS 2nd call returns identical data (server log cache-hit line not checkable from QA session)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Second call identical; the cache-hit log line isn't visible to the client.

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account): second summary logged four Sheet data cache hit: <copy>/<sheetId> lines. kai-oauth (OAuth): three Sheet data cache hit lines, 0.32 s against 1.06 s uncached. Same data both times. Log lines now read Sheet data cache hit: … in /tmp/mcp-gee-sweet.log, not make logs.


TC-R18: rows_to_fetch=1 — only header returned

Prompt

"Give me a summary of {SPREADSHEET_ID} fetching only 1 row per sheet"

Checks - headers contains the header row for Sales - first_rows is empty (no data rows beyond the header)

Result (2026-09-04) ✅ PASS rows_to_fetch=1 → headers present, first_rows []

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R19: rows_to_fetch=0 — clamped to 1

Prompt

"Summarize {SPREADSHEET_ID} with rows_to_fetch set to 0"

Checks - Server clamps to 1 (max(1, 0)) - Behaves identically to TC-R18 - 🔍 Product decision: should 0 return only headers, or is clamping to 1 the right behavior?

Result (2026-07-04) ❌ FAIL, then fixed On a cold cache, correctly clamped (data.py:304's max(1, rows_to_fetch)). On a warm cache, cache.py:189's truncation slice (first_rows[:rows_to_fetch - 1]) lacked the same clamp — rows_to_fetch=0 became [:-1] and returned 3 rows instead of an empty list, disagreeing with the cold-cache result for the same input. Filed as #254, fixed in #257 (applies the same clamp on the cache-hit path). Re-verified live (2026-07-05) after merge: warmed the cache with rows_to_fetch=5, then called rows_to_fetch=0 — first_rows: [] for Sales, matching cold-cache behavior.

Result (2026-09-04) ✅ PASS rows_to_fetch=0 clamped to 1: first_rows [] for Sales, same as TC-R18

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R20: Spreadsheet with empty sheet

Prompt

"Summarize {SPREADSHEET_ID} — I want to see what the Empty sheet looks like in the summary"

Checks - Empty sheet entry: headers: [], first_rows: [] - No error field for the empty sheet - Other sheets unaffected

Result (2026-09-04) ✅ PASS Empty sheet entry headers [] / first_rows []; no error; other sheets fine

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R21: Invalid spreadsheet ID in list

Prompt

"Summarize these two spreadsheets: {SPREADSHEET_ID} and 'invalidid123xyz'"

Checks - Valid spreadsheet returns normal summary - Invalid ID entry has an error field - Both results present — partial failure, not a top-level error

Result (2026-09-04) ✅ PASS Valid → normal summary; "invalidid123xyz" → error field; both present

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R22: Range format verification

Prompt

"Give me a summary of {SPREADSHEET_ID} with rows_to_fetch=3"

Checks - Sales sheet returns header + 2 data rows (rows 2–3) - Verify data from columns B, C, D is present — not just column A - 🔍 Product decision: A1:3 range format — does the API return all columns or just column A? See notes-read.md

Result (2026-09-04) ✅ PASS rows_to_fetch=3 → Sales header + 2 data rows (Widget, Gadget); cols B/C/D present

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R40: get_multiple_spreadsheet_summary emits notifications/progress updates as each spreadsheet finishes (issue #355)

Background: Same #355 pattern as TC-R39, applied to get_multiple_spreadsheet_summary's concurrent per-spreadsheet outer loop (the same loop TC-R37 exercises for cross-attribution): ctx.report_progress(completed, total, f"{spreadsheet_id}: {outcome}") fires from inside each spreadsheet's own coroutine as its summary finishes. Unit-tested against a mocked ctx in tests/sheets/test_data.py::TestGetMultipleSpreadsheetSummary::test_reports_progress_per_spreadsheet/test_report_progress_failure_does_not_demote_a_successful_summary.

Note: Same protocol-level caveat as TC-D199/TC-R39 — visibility depends on whether this QA client sets a progressToken.

Prompt

"Give me summaries of {SPREADSHEET_ID} and 'invalidid123xyz' in one call"

Checks - Call completes normally, returns 2 entries (one with an error field for the invalid ID), no top-level error - If progress notifications are visible in the client: 2 discrete updates appear, one per spreadsheet, not a single update at the very end

Teardown None.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Correct results; progress notifications aren't visible in this client.


find_in_spreadsheet

TC-R23: Match found in specific sheet

Prompt

"Find 'Gadget' in the Sales sheet of {SPREADSHEET_ID}"

Checks - Returns at least one match with row/column/value information - Match is in the Sales sheet, row 3

Result (2026-09-04) ✅ PASS Sales A3 "Gadget" (row 3)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.

Result (2026-10-19) ✅ PASS — v0.9.3 release pass kai-sa: find_in_spreadsheet("Gadget", sheet=Sales) returned one match: Sales A3 "Gadget" (row 3).

Result (2026-10-20) ✅ PASS — v0.9.3 post-advisory re-check @ c90b88d3 kai-sa (service account) and kai-oauth (OAuth): find_in_spreadsheet("Gadget", Sales) → Sales A3.


TC-R24: Match across all sheets

Prompt

"Search for 'Setup complete' across all sheets in {SPREADSHEET_ID}"

Checks - Match found in Notes & Misc sheet - No sheet filter applied — all sheets searched

Result (2026-09-04) ✅ PASS Match in 'Notes & Misc' B2; all sheets searched

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R25: Case-insensitive match (default)

Prompt

"Find 'gadget' (lowercase) in {SPREADSHEET_ID}"

Checks - Returns match for "Gadget" despite case difference - Confirms default is case-insensitive

Result (2026-09-04) ✅ PASS lowercase 'gadget' matched "Gadget" (case-insensitive default)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R26: Case-sensitive match

Prompt

"Find 'gadget' in {SPREADSHEET_ID} using case-sensitive matching"

Checks - Returns no matches (fixture data has "Gadget" with capital G) - Confirms case-sensitive flag is respected

Result (2026-09-04) ✅ PASS case_sensitive=true 'gadget' → []

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R27: max_results respected

Prompt

"Find 'Q' in {SPREADSHEET_ID} but limit results to 2"

Checks - Returns exactly 2 results (headers Q1, Q2, Q3 would otherwise produce 3+) - No more than max_results entries in response

Result (2026-09-04) ✅ PASS 'Q' max_results=2 → exactly 2 results

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R28: No matches

Prompt

"Find 'ZZZnoMatch' in {SPREADSHEET_ID}"

Checks - Returns [] — empty list, not an error

Result (2026-09-04) ✅ PASS 'ZZZnoMatch' → []

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R29: Sheet name not found

Prompt

"Find 'Widget' in a sheet called 'DoesNotExist' in {SPREADSHEET_ID}"

Checks - Returns [{"error": ...}] — error entry, not a top-level exception - Error message references the sheet name

Result (2026-09-04) ✅ PASS sheet 'DoesNotExist' → [{"error":"Sheet 'DoesNotExist' not found"}], names the sheet

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R30: Multiple column matches in same row

Prompt

"Find 'Q' in the Sales sheet of {SPREADSHEET_ID}"

Checks - Returns separate results for Q1, Q2, Q3 in row 1 (each column is its own result) - Confirms per-cell result granularity, not per-row

Result (2026-09-04) ✅ PASS 'Q' in Sales → separate results for Q1/Q2/Q3 (per-cell granularity)

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


get_sheet_data — effectiveFormat assertions

These tests follow a setup→assert→teardown pattern. Apply format via batch_update, assert via get_sheet_data(include_grid_data=True), then clear via batch_update { cell: {}, fields: "userEnteredFormat" }.

See docs/design/effectiveformat-spike.md for full field reference and RGB precision notes.


TC-R31: Bold text format readable via effectiveFormat

Setup Apply bold to Sales A1 (sheetId=0, row 0, col 0) via batch_update → repeatCell, field mask userEnteredFormat.textFormat.bold.

Prompt

"Get Sales A1:A1 from {SPREADSHEET_ID} with include_grid_data=True"

Checks - sheets[0].data[0].rowData[0].values[0].effectiveFormat.textFormat.bold is true - formattedValue is "Product" (value unchanged) - effectiveFormat.textFormat.italic is false (only bold set)

Teardown Clear userEnteredFormat from A1 via batch_update → repeatCell { cell: {}, fields: "userEnteredFormat" }.

Result (2026-06-20) ✅ PASS - effectiveFormat.textFormat.bold = true, italic = false, formattedValue = "Product"

Result (2026-09-04) ✅ PASS A1 effectiveFormat.textFormat.bold=true, italic=false, formattedValue "Product"; format cleared in teardown

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R32: Background color and italic readable via effectiveFormat

Setup Apply italic=true and backgroundColor={red:1, green:0.9, blue:0.6} to Sales B1 (sheetId=0, row 0, col 1) via batch_update → repeatCell, field mask userEnteredFormat.backgroundColor,userEnteredFormat.textFormat.italic.

Prompt

"Get Sales B1:B1 from {SPREADSHEET_ID} with include_grid_data=True"

Checks - effectiveFormat.textFormat.italic is true - effectiveFormat.backgroundColor.red ≈ 1.0 - effectiveFormat.backgroundColor.green ≈ 0.898 (API returns 229/255 ≈ 0.8980392) - effectiveFormat.backgroundColor.blue ≈ 0.6 - effectiveFormat.textFormat.bold is false

Teardown Clear userEnteredFormat from B1.

Result (2026-06-20) ✅ PASS - effectiveFormat.textFormat.italic = true, bold = false, backgroundColor = {red:1, green:0.8980392, blue:0.6}

Result (2026-09-04) ✅ PASS B1 effectiveFormat italic=true, bold=false, backgroundColor {red:1,green:0.8980392,blue:0.6}; cleared

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R33: Number format and formattedValue readable via effectiveFormat

Setup Apply numberFormat={type:"CURRENCY", pattern:"$#,##0.00"} to Sales B2 (sheetId=0, row 1, col 1) via batch_update → repeatCell, field mask userEnteredFormat.numberFormat. (B2 contains the value 100.)

Prompt

"Get Sales B2:B2 from {SPREADSHEET_ID} with include_grid_data=True"

Checks - effectiveFormat.numberFormat.type is "CURRENCY" - effectiveFormat.numberFormat.pattern is "$#,##0.00" - formattedValue is "$100.00" - effectiveValue.numberValue is still 100 (underlying value unchanged)

Teardown Clear userEnteredFormat from B2.

Result (2026-06-20) ✅ PASS - effectiveFormat.numberFormat = {type:"CURRENCY", pattern:"$#,##0.00"}, formattedValue = "$100.00", effectiveValue.numberValue = 100

Result (2026-09-04) ✅ PASS B2 numberFormat {CURRENCY, "$#,##0.00"}, formattedValue "$100.00", effectiveValue.numberValue 100; cleared

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.


TC-R34: get_multiple_sheet_data — many small queries trips the response-size cap (issue #242)

Background: #242 generalized #235's response-size safety net beyond get_sheet_data. For get_multiple_sheet_data, the unbounded axis is query count, not just per-query range size — many small results can add up.

Setup No fixture setup needed — repeat the same tiny query ({spreadsheet_id: {SPREADSHEET_ID}, sheet: "Sales"}, a 6x4 range) 200 times in one queries list.

Checks - Call raises ValueError mentioning the actual response size, the 40,000-character cap, and MAX_TOOL_RESPONSE_CHARS - Same call with only 2 queries + local_path set succeeds, returns {local_path, query_count, bytes_written}, and the file on disk contains the full per-query results

Result (2026-07-03) ✅ PASS 200 queries against the small Sales range (6 rows x 4 cols) raised: get_multiple_sheet_data: the response is 150106 characters, over the 40000-character safety cap. Pass local_path to write the result to disk instead of returning it inline (bypasses this cap), or set MAX_TOOL_RESPONSE_CHARS if your MCP client can handle larger responses (e.g. a raised MAX_MCP_OUTPUT_TOKENS). local_path with 2 queries succeeded, returned {"local_path":"/tmp/qa_multiple_sheet_data_242.json","bytes_written":2048,"query_count":2}; file verified on disk then cleaned up.

Result (2026-09-04) ✅ PASS local_path branch: 2 queries → {local_path, bytes_written 440, query_count 2}, file has full per-query results. Cap-trip check (200 queries → ValueError naming 40,000 cap) is stale: #519 raised default to 1,000,000, a ~150k-char 200-query response no longer trips it; cap mechanism itself verified via TC-R03c

Result (2026-10-17) ⚠️ PARTIAL — v0.9.2 release pass local_path part passes; the 200-query cap check is stale since #519 and was not run. Corrected (2026-10-17, #1044 audit): the 200-query cap check wasn't run.


TC-R35: find_in_spreadsheet — max_results bounds count, not size (issue #242)

Background: max_results (default 50) caps how many matches are returned, not how large each matched cell value is. A handful of large matching cells can exceed the response-size cap even with few matches.

Setup Write 10 cells (Empty!A1:A10) each containing a ~4,785-character string with a shared marker substring (e.g. repeated "PADTEST marker text..." sentence), then search for the marker.

Checks - Call raises ValueError mentioning the actual response size and the cap, even though match count (10) is well under max_results (50) - Same call with local_path set succeeds, returns {local_path, spreadsheet_id, query, match_count, bytes_written}, and the file on disk contains all matches

Teardown clear_values on the Empty sheet to remove the test data.

Result (2026-07-03) ✅ PASS 10 matches (42,491 chars total) raised: find_in_spreadsheet: the response is 42491 characters, over the 40000-character safety cap. ... despite being far under the max_results=50 default — confirming match-count capping alone doesn't bound response size. local_path call succeeded: {"local_path":"/tmp/qa_find_in_spreadsheet_242.json","bytes_written":42491,"spreadsheet_id":"...","query":"PADTEST","match_count":10}; file verified then cleaned up. Test data cleared from the Empty sheet afterward.

Result (2026-09-04) ⏭️ SKIP Stale: references retired 40,000 cap; ~42k-char 10-match response no longer trips the 1,000,000 default. Cap + local_path mechanism covered by TC-R03c/d. Skipped rather than add fixture churn for no signal

Result (2026-10-17) ✅ PASS — v0.9.2 release pass 40 REPT cells of 30k chars (1,201,911 chars) trip the 1M cap; local_path returns match_count 40. TC setup still cites the 40k cap (stale).


TC-R44: A sheet name with / or .. writes inside the chosen local_path directory (PR #1000 QA round 2, issue #990) ⚠️ local-filesystem

get_sheet_data names its file <sheet>_data.json. Before the fix, a tab named ../x wrote x_data.json beside the chosen directory, and a tab named a/b created a subdirectory.

Setup: rm -rf /tmp/qa-990-sheet. create_sheet(spreadsheet_id={SPREADSHEET_ID}, title="../x") and create_sheet(spreadsheet_id={SPREADSHEET_ID}, title="a/b").

Steps 1. get_sheet_data(spreadsheet_id={SPREADSHEET_ID}, sheet="../x", local_path="/tmp/qa-990-sheet/out/") 2. get_sheet_data(spreadsheet_id={SPREADSHEET_ID}, sheet="a/b", local_path="/tmp/qa-990-sheet/out/")

Checks - Step 1 returns local_path /tmp/qa-990-sheet/out/.._x_data.json - Step 2 returns local_path /tmp/qa-990-sheet/out/a_b_data.json - ls -A /tmp/qa-990-sheet lists only out, and ls -A /tmp/qa-990-sheet/out lists exactly .._x_data.json and a_b_data.json (no a/ subdirectory)

Cleanup: delete_sheet both tabs; rm -rf /tmp/qa-990-sheet

Result (2026-10-11) ✅ PASS — Kit, PR #1000 round 3 (290f5bc). Ran under this job's scratch dir instead of /tmp. Used TEST_SPREADSHEET_ID. Step 1 returned <dir>/out/.._x_data.json (117 bytes), step 2 <dir>/out/a_b_data.json (116 bytes). <dir> lists only out, and out lists exactly those two files. Both tabs deleted afterward.

Result (2026-10-17) ✅ PASS — v0.9.2 release pass Run 1 (kai-sa) or run 2 re-run (kai-oauth); observed values matched the TC checks.