Skip to content

Docs Tools — Content (Create/Read/Write, Markdown/HTML Conversion) — QA Test Cases

Source: src/mcp_gee_sweet/tools/docs/content.py, plus four submodules bundled here rather than split into their own files (this file is the catch-all for everything issue #233's split didn't carve out a dedicated file for): editing.py (insert_doc_text, delete_doc_range, insert_page_break, insert_softbreak_paragraph), images.py (insert_inline_image, insert_local_images), comments.py (list_doc_comments, add_doc_comment, resolve_doc_comment), named_ranges.py (create_named_range, create_bookmark) — plus the markdown/HTML conversion pipeline these tools drive: ast.py, html_parser.py, emitter.py, anchors.py, indices.py.

Fixtures: see docs/qa/setup.md. Substitute {DOC_ID} (and, for insert_local_images, {FOLDER_ID}) from fixtures.local.md.

These tools operate on document body indices. Use get_doc_structure first in any session to obtain current indices before calling insert/delete/style operations.


create_doc

TC-D07: Create with no content ⚠️ requires-oauth

Prompt

"Create a Google Doc called 'QA-Empty-Doc' with no content"

Checks - Doc created successfully - No batchUpdate call made (no content to write) - Response includes doc ID and web link

Result (2026-09-04) ✅ PASS QA-Empty-Doc created (docId 10XUmZffu...), no content, no error. Trashed.


TC-D08: Create with HTML content — formatting preserved ⚠️ requires-oauth

Prompt

"Create a Google Doc called 'QA-Formatted-Doc' with this content: <h1>Main Title</h1><p>A paragraph.</p><ul><li>Item A</li><li>Item B</li></ul>"

Checks - Doc created with correct title - Open the doc in a browser: heading renders as H1, bullets render as a list - Confirms the create_doc bug fix: uses _html_to_doc_requests, not _html_to_text

Result (2026-09-04) ✅ PASS HTML converted correctly: HEADING_1 "Main Title", paragraph "A paragraph.", bullets "Item A"/"Item B" as list items (get_doc_structure confirmed). Trashed.


Prompt

"Create a Google Doc called 'QA-Link-Doc' with content: <p>Visit <a href=\"https://example.com\">Example</a></p>"

Checks - Doc created - Open in browser: "Example" is a clickable link to https://example.com

Result (2026-09-04) ✅ PASS "Example" run has link_url=https://example.com, underline styling. Trashed.


TC-D10: Content with no block-level elements — batchUpdate skipped ⚠️ requires-oauth

Prompt

"Create a Google Doc called 'QA-Inline-Doc' with content: <span>just a span</span>"

Checks - Doc created without error - No batchUpdate call (inline-only HTML produces no requests) - Doc body is empty (span is not a block element)

Result (2026-09-04) ✅ PASS Body empty (only terminal blank paragraph) — inline-only span produced no content. Trashed.


TC-D11: Drive folder cache invalidated ⚠️ requires-oauth

Prompt

"Create a doc called 'QA-DocCache' in {FOLDER_ID}, then list the files in that folder"

Checks - list_files includes 'QA-DocCache' - Confirms drive_folder_cache.mark_dirty fires after doc creation

Result (2026-09-04) ✅ PASS list_files(FOLDER_ID) included QA-DocCache after create. Trashed.


TC-D12: Long content ⚠️ requires-oauth

Prompt

"Create a Google Doc called 'QA-Long-Doc' with a very long paragraph — repeat the word 'test ' 500 times as the body content"

Checks - Doc created without error - Content visible in the doc - Note any API size limit errors

Result (2026-09-04) ✅ PASS 500x "test " paragraph created without error, content visible (endIndex 2412), no size-limit error. Trashed.


get_doc_content

TC-D44: Happy path

Prompt

"Get the content of doc {DOC_ID}"

Checks - Returns text content with the expected HTML: heading, paragraph, list items - Response includes metadata (title, web link) - No error field

Result (2026-09-04) ✅ PASS get_doc_content returned text content: "Test Document", paragraph, " Item one"/" Item two", metadata (name, modified_time, web_link), no error.


TC-D45: Cache hit on second call

Prompt (run twice)

"Get the content of doc {DOC_ID} again"

Checks - Second call returns same content - Logs show cache hit

Result (2026-09-04) ✅ PASS Second call returned identical content to TC-D44 (cache-hit content match; log-level cache-hit confirmation not directly observable via MCP tool response).


TC-D46: Non-Google-Doc file ID

Prompt

"Get the content of {SPREADSHEET_ID} using get_doc_content"

Checks - Drive export API returns an error (spreadsheets can't be exported as plain text this way) - Error propagates cleanly — not a server crash

Result (2026-09-04) ✅ PASS get_doc_content against SPREADSHEET_ID returned clean HttpError 400 "The requested conversion is not supported." — propagated as tool error, not a crash.


TC-D47: Non-existent file ID

Prompt

"Get the content of doc 'invalidid123xyz'"

Checks - Returns a clear API error - Not a silent empty response

Result (2026-09-04) ✅ PASS get_doc_content('invalidid123xyz') returned clean HttpError 404 "File not found: invalidid123xyz." — not silent empty.


TC-D48: Large document

Prompt

"Get the content of a large Google Doc — if you have one, use its ID"

Checks - Content returned without timeout or truncation - Note any response size limits observed

Result (2026-09-04) ✅ PASS get_doc_content on TEST_LARGE_DOC_ID (mcp-gee-sweet-qa-large-doc, ~53.6KB content) returned without truncation or timeout — well under current MAX_TOOL_RESPONSE_CHARS default (1,000,000, raised by #519). No response-size limit hit at this fixture size.


TC-D49: Content decode branch

Prompt

"Get the content of {DOC_ID} and tell me if the content came back as bytes or a string"

Checks - Content decoded correctly regardless of whether the API returns bytes or string - 🔍 Implementation note: content.decode("utf-8") vs already-string branch in drive.py

Result (2026-09-04) ✅ PASS Content decoded as plain string (not bytes) — matches TC-D44's content, no decode artifacts.


TC-DOC80: get_doc_content trips the response-size cap; cached path re-checks it too (issue #242) ⚠️ low-cap override required

Background: #242 generalized #235's response-size safety net to get_doc_content. doc_cache previously returned a cached result before any cap check ran, so a cached oversized doc would bypass the cap on repeat calls — fixed so the check runs on both the cache-hit and cache-miss paths.

Run method (issue #678): MAX_TOOL_RESPONSE_CHARS is read once at tools/response_limits.py import, and #519 raised its default from 40000 to 1,000,000. The TEST_LARGE_DOC_ID fixture (~54k chars, sized for TC-D48) is far under that default, so it cannot trip the cap on a normally-started server. This case is therefore not run through the shared shard server — instead run it against the low cap, one of: - a purpose-started server with MAX_TOOL_RESPONSE_CHARS=40000 in its env, or - a direct script invocation: MAX_TOOL_RESPONSE_CHARS=40000 uv run python3 -c "..." from the repo root, calling the real get_doc_content tool function (import from mcp_gee_sweet.tools.docs) against TEST_LARGE_DOC_ID with a real OAuth docs/drive service (mcp_gee_sweet.auth._oauth_creds() + googleapiclient.discovery.build). This exercises the real code path and the real Docs API — record the Result as a live verification, noting it was script-driven rather than through the MCP tool wrapper.

Setup: TEST_LARGE_DOC_ID (mcp-gee-sweet-qa-large-doc). Measure its current serialized get_doc_content size at run time and put the number in the Result — it just needs to exceed 40000, which it comfortably does at ~54k.

Checks - First call (fetch path), cap = 40000: raises ValueError naming the actual response size, the 40000-character cap, and MAX_TOOL_RESPONSE_CHARS - Second call (cache-hit path, no refresh_cache in between): raises the same error — proves the cache-hit path re-checks the cap rather than returning the stale oversized cached result - Same call with local_path set: succeeds, returns {local_path, id, bytes_written}, and the file on disk contains the full content

Result (2026-07-03) ✅ PASS — superseded, needs re-run under the #678 method. Prior run, against the then-default 40000 cap with a ~49,700-char fixture: Fetch-path call raised get_doc_content: the response is 49700 characters, over the 40000-character safety cap. …; repeat call served from doc_cache (no extra Drive round-trip) raised the identical error; local_path call succeeded (bytes_written: 49700), file verified then cleaned up. Not valid for v0.9.0 — the cap default and fixture size both changed since; re-run per the Run method above.

Result (2026-09-04) ✅ PASS Script-driven at MAX_TOOL_RESPONSE_CHARS=40000 (repo-root uv run python3, real OAuth creds): fetch-path raises ValueError naming 54891 chars/40000 cap/MAX_TOOL_RESPONSE_CHARS; cache-hit path (real DocContentCache store+get round-trip, content byte-identical) still raises — confirms #242 cache-ordering fix; local_path via real write_capped_result_to_disk returns {local_path,bytes_written:54891,id}, on-disk content matches exactly. Supersedes the 2026-07-03 result (old 40000 default, ~49,700-char fixture) — see #678.


write_doc_content

TC-D50: Write to an empty doc ⚠️ requires-oauth

Prompt

"Create a new empty doc called 'QA-WriteEmpty', then write this content to it: <h1>Hello</h1><p>World</p>"

Checks - Doc content replaced with heading and paragraph - end_index=2 path taken (doc was empty — no delete step needed) - Open in browser to verify formatting

Result (2026-09-04) ✅ PASS Created QA-WriteEmpty, wrote

Hello

World

. get_doc_structure: HEADING_1 "Hello" then paragraph "World". Trashed.


TC-D51: Write to a doc with existing content ⚠️ destructive

Prompt

"Overwrite the content of {DOC_ID} with: <h2>Replaced</h2><p>New content only.</p>"

Checks - Previous content cleared - New heading and paragraph visible in the doc - doc_cache.mark_dirty called — next get_doc_content re-fetches

Result (2026-09-04) ✅ PASS Overwrote DOC_ID with

Replaced

New content only.

. get_doc_structure confirmed prior content cleared, HEADING_2 "Replaced" + paragraph "New content only." present.


TC-D52: HTML with headings and bullets

Prompt

"Write this HTML to {DOC_ID}: <h1>Title</h1><h2>Subtitle</h2><ul><li>A</li><li>B</li></ul><p>Footer</p>"

Checks - H1 renders as Heading 1, H2 as Heading 2 - A and B render as bullet list items - Footer renders as normal paragraph

Result (2026-09-04) ✅ PASS Wrote

Title

Subtitle

  • A
  • B

Footer

. Confirmed HEADING_1/HEADING_2/bullets A,B (shared listId)/paragraph Footer.


Prompt

"Write this to {DOC_ID}: <p>Click <a href=\"https://example.com\">here</a> for more</p>"

Checks - "here" is a clickable hyperlink to https://example.com - Surrounding text renders as normal paragraph

Note: write_doc_content replaces the full document content, so this test is self-contained regardless of run order.

Result (2026-09-04) ✅ PASS Wrote link HTML. "here" run has link_url=https://example.com, surrounding text plain.


TC-D54: HTML with no recognizable tags

Prompt

"Write <span>no blocks here</span> to {DOC_ID}"

Checks - Existing content cleared (delete step runs) - Nothing inserted (span produces no block-level requests) - Doc body is empty

Result (2026-09-04) ✅ PASS Wrote no blocks here. Existing content cleared, body empty (only terminal blank paragraph) — span produced no block requests.


TC-D55: Empty string content

Prompt

"Write an empty string to {DOC_ID}"

Checks - Existing content cleared - Nothing inserted - Doc body is empty

Result (2026-09-04) ✅ PASS Wrote empty string. Existing content cleared, body empty.


TC-D56: Very long content

Prompt

"Write a very long document to {DOC_ID} — use 100 paragraphs each with 50 words of placeholder text"

Checks - Writes successfully or returns a clear API size limit error (~2MB per batchUpdate request) - Note any limit encountered

Note: Content is generated inline by the conductor — no fixture file needed.

Result (2026-09-04) ✅ PASS Wrote 100 paragraphs x 50 "words" each (~11KB). Write succeeded without error, well under 2MB batchUpdate limit.


TC-D57: Cache invalidated after write

Prompt

"Write <p>CacheTest</p> to {DOC_ID}, then immediately get the doc content"

Checks - get_doc_content returns 'CacheTest' — not the old cached version - Confirms doc_cache.mark_dirty fires after write

Result (2026-09-04) ✅ PASS Wrote

CacheTest

, immediately get_doc_content returned "CacheTest" — not stale cache, confirms mark_dirty fires after write.


write_doc_content — table support (issue #62)

TC-D140: Simple 2×2 table created from HTML

Prompt

"Write this HTML to {DOC_ID}: <table><tr><th>Name</th><th>Value</th></tr><tr><td>Alpha</td><td>1</td></tr></table>"

Checks - A real Google Docs table is visible in the doc — NOT flattened plain text - Table has 2 rows and 2 columns - Header row contains "Name" and "Value"; data row contains "Alpha" and "1" - Open in browser to verify

Result (2026-09-04) ✅ PASS 2x2 real Docs table (not flattened), header "Name"/"Value", data "Alpha"/"1".


TC-D141: Table after paragraph content

Prompt

"Write this HTML to {DOC_ID}: <h1>Batch Comparison</h1><p>See the table below.</p><table><tr><th>Original</th><th>Double</th></tr><tr><td>2 cups flour</td><td>4 cups flour</td></tr><tr><td>1 egg</td><td>2 eggs</td></tr></table>"

Checks - Doc has "Batch Comparison" as a Heading 1 - "See the table below." renders as a paragraph - A 3-row × 2-column table is present after the paragraph - Table cells contain correct text: "Original", "Double", "2 cups flour", "4 cups flour", etc. - Table appears after the paragraph content (interleaved in HTML order)

Result (2026-09-04) ✅ PASS HEADING_1 "Batch Comparison", paragraph "See the table below.", 3x2 table after paragraph, cells correct (Original/Double/2 cups flour/4 cups flour/1 egg/2 eggs).


TC-D142: Table with empty cells

Prompt

"Write this HTML to {DOC_ID}: <table><tr><td>A</td><td></td></tr><tr><td></td><td>D</td></tr></table>"

Checks - 2×2 table created - Cell (0,0) = "A", cell (0,1) = empty, cell (1,0) = empty, cell (1,1) = "D" - Empty cells don't cause an error — insertText is simply skipped for them

Result (2026-09-04) ✅ PASS 2x2 table, cell(0,0)="A", (0,1)="", (1,0)="", (1,1)="D" — empty cells no error.


TC-D143: Table-only HTML (no paragraphs)

Prompt

"Write this HTML to {DOC_ID}: <table><tr><td>X</td><td>Y</td></tr></table>"

Checks - A 1-row × 2-column table is created - Cells contain "X" and "Y" - No paragraph text before the table - Confirms the early-return guard correctly handles tables-only input

Result (2026-09-04) ✅ PASS 1x2 table only (X/Y), no paragraph text before table (only structural blank paragraph). Early-return guard confirmed.


TC-D144: Multiple tables in one write

Prompt

"Write this HTML to {DOC_ID}: <p>First table:</p><table><tr><td>A</td><td>B</td></tr></table><p>Second table:</p><table><tr><td>C</td><td>D</td></tr></table>"

Checks - Both tables are created in the document - First table has cells "A" and "B"; second has "C" and "D" - "First table:" and "Second table:" paragraphs appear before both tables - No index corruption or API error between the two table insertions

Result (2026-09-04) ✅ PASS Both tables created correctly (A/B then C/D), "First table:"/"Second table:" paragraphs before each, no index corruption.


TC-D145: HTML with <th> header cells treated as data

Prompt

"Write this HTML to {DOC_ID}: <table><tr><th>Col1</th><th>Col2</th></tr><tr><td>Val1</td><td>Val2</td></tr></table>"

Checks - <th> cells are included in the table (not ignored) - First row contains "Col1" and "Col2", second row contains "Val1" and "Val2" - Google Docs doesn't distinguish th vs td styling — both rows are plain table cells

Result (2026-09-04) ✅ PASS

cells included as normal cells: row0 Col1/Col2, row1 Val1/Val2. --- ## `get_doc_structure` ### TC-DOC01: Structure of a non-empty doc **Prompt** > "Get the structure of doc {DOC_ID}" **Checks** - Returns `docId`, `title`, and `elements` list - Each element has `type`, `startIndex`, `endIndex` - Paragraphs include `namedStyleType`, `text`, and `runs` - First element is a `sectionBreak` at index 0 - Last element is a paragraph ending at the document's total length **Result (2026-06-20) ✅ PASS** - Returned `docId`, `title`, `elements` list. sectionBreak at index 0. Paragraphs include `namedStyleType`, `text`, `runs`. Final paragraph ends at document total length. **Result (2026-09-04) ✅ PASS** Confirmed throughout session: docId/title/elements present, sectionBreak at index 0, paragraphs include namedStyleType/text/runs, final paragraph ends at doc total length. --- ### TC-DOC02: Paragraph runs include style data **Setup:** `{DOC_ID}` must contain at least one bold or italic run (use `write_doc_content` with `` or `` to set up) **Prompt** > "Get the structure of doc {DOC_ID} and show me the formatting on each run" **Checks** - Runs with bold styling return `bold: true` - Runs without explicit style return `bold: null` (not `false`) — null means inherited - `link_url` is populated for runs inside `` tags **Result (2026-06-20) ✅ PASS** - Wrote `bold and italic and a link`. "Bold text" run: `bold: true`. Plain text runs: `bold: null` (not false). Link run: `link_url: "https://example.com"`. Null semantics confirmed. **Result (2026-09-04) ✅ PASS** Wrote bold text and italic text and a link. "bold text" run bold:true, "italic text" italic:true, plain-text runs bold:null (not false), link run link_url="https://example.com". --- ### TC-DOC03: Structure of a doc containing a table **Setup:** `{DOC_ID}` must contain a table **Prompt** > "Get the structure of doc {DOC_ID}" **Checks** - Table element has `type: "table"`, `rows`, `columns` - `cells` list contains one entry per cell with `row`, `col`, `startIndex`, `endIndex`, `paragraphStartIndex` - `paragraphStartIndex` is one greater than cell `startIndex` (empty cell: paragraph is the only content) - Cell text is populated correctly for non-empty cells **Result (2026-06-20) ✅ PASS** - Inserted a 2×2 table; `get_doc_structure` returned `type: "table"`, `rows: 2`, `columns: 2`, 4 cells. Each cell: `paragraphStartIndex = startIndex + 1`. Cell `text: ""` for all empty cells. **Result (2026-09-04) ✅ PASS** Confirmed via TC-D140/142/145 table writes: type:"table", rows/columns, cells list with row/col/startIndex/endIndex/paragraphStartIndex; empty cell paragraphStartIndex = startIndex+1 (e.g. D142 cell (0,1): startIndex 7, paragraphStartIndex 8). --- ### TC-DOC04: Structure of an empty doc **Setup:** doc with only the default empty paragraph **Prompt** > "Get the structure of doc {DOC_ID}" **Checks** - Returns elements with at least the sectionBreak and one empty paragraph - No error **Result (2026-06-20) ✅ PASS** - Wrote `

`. Structure: sectionBreak at 0–1, one empty paragraph at 1–2. No error. **Result (2026-09-04) ✅ PASS** Confirmed via TC-D55 (empty string write): sectionBreak + one empty paragraph, no error. --- ### TC-DOC05: Invalid doc ID returns error **Prompt** > "Get the structure of doc not-a-real-id" **Checks** - Returns `{"error": "..."}` — does not raise an exception - Error message references the invalid ID or a 404 **Result (2026-06-20) ✅ PASS** - Returned `{"error": ""}`. No exception raised. **Result (2026-09-04) ✅ PASS** get_doc_structure('not-a-real-id') returned {"error": ""}. No exception raised. --- ## `insert_doc_text` ### TC-DOC06: Insert a single paragraph ⚠️ destructive **Setup:** fetch current structure; note the `endIndex` of the last non-final paragraph **Prompt** > "Insert the text 'Inserted line.\n' at index {N} in doc {DOC_ID}" **Checks** - Re-fetch structure shows new paragraph at the expected position - Surrounding paragraphs shifted by the length of the inserted text - `insertions: 1` in response **Cleanup:** delete the inserted range after verifying **Result (2026-06-20) ✅ PASS** - Inserted "Inserted line.\n" at index 88. Re-fetch showed new paragraph at 88–103. "Item two\n" unchanged; final blank shifted to 103–104. `insertions: 1`. **Result (2026-09-04) ✅ PASS** Inserted "Inserted line.\n" at index 38. Re-fetch: new paragraph at 38-53, insertions:1. --- ### TC-DOC07: Insert at multiple indices — high→low ordering verified ⚠️ destructive **Setup:** fetch structure; identify two paragraphs P1 (earlier) and P2 (later) with known indices. Record P1's `startIndex` as N1 and P2's `startIndex` as N2 (N2 > N1). Both insertions are short fixed strings so index arithmetic is checkable. **Prompt** > "Insert 'AAA\n' at index {N1} and 'BBB\n' at index {N2} in doc {DOC_ID}" **Checks** - Re-fetch structure shows 'AAA' before P1 and 'BBB' before P2 (not shifted into wrong paragraphs) - 'BBB' paragraph's `startIndex` = N2 + 4 (len('AAA\n') inserted before it) - If tool processed low→high instead, 'BBB' would land 4 bytes early — use this arithmetic to confirm ordering - `insertions: 2` in response **Cleanup:** delete both inserted ranges **Result (2026-06-20) ✅ PASS** - N1=70 (Item one startIndex), N2=79 (Item two startIndex). After insert: "AAA\n" at 70–74 before Item one; "BBB\n" at 83–87 before Item two. BBB startIndex = N2+4 = 83 ✅. `insertions: 2`. High→low ordering confirmed. **Result (2026-09-04) ✅ PASS** Inserted 'AAA\n' at N1=1, 'BBB\n' at N2=38. Re-fetch: AAA before P1, BBB before P2 at startIndex 42 = N2+4. insertions:2. High->low ordering confirmed. --- ### TC-DOC08: Empty insertions list returns error **Prompt** > "Call insert_doc_text on doc {DOC_ID} with an empty insertions list" **Checks** - Returns `{"error": "insertions list is empty"}` **Result (2026-06-20) ✅ PASS** - Returned `{"error": "insertions list is empty"}`. **Result (2026-09-04) ✅ PASS** insert_doc_text with empty insertions list returned {"error":"insertions list is empty"}. --- ## `delete_doc_range` ### TC-DOC09: Delete a paragraph ⚠️ destructive **Setup:** insert a known paragraph first (TC-DOC06), note its `startIndex` and `endIndex` **Prompt** > "Delete the range from index {start} to {end} in doc {DOC_ID}" **Checks** - Re-fetch structure no longer contains the deleted paragraph - Surrounding content shifted back correctly - `deletions: 1` in response **Result (2026-06-20) ✅ PASS** - Inserted "Delete me.\n" at 88; deleted [88, 99]. Re-fetch confirmed paragraph absent; "Item two\n" back at 79–88; final blank at 88–89. `deletions: 1`. **Result (2026-09-04) ✅ PASS** Deleted [46,61) ("Inserted line." paragraph). Re-fetch: paragraph absent, BBB back at 42-46, final blank at 46-47. deletions:1. --- ### TC-DOC10: Cannot delete final segment newline **Setup:** fetch structure; note the final element's `endIndex` **Prompt** > "Delete the range from index 1 to {final_endIndex} in doc {DOC_ID}" **Checks** - Returns an API error about the segment newline - 🔍 **Note:** correct usage is `endIndex - 1` for the final element **Result (2026-06-20) ✅ PASS** - Attempted delete [1, 89] (final_endIndex=89). API returned `{"error": ""}`. **Result (2026-09-04) ✅ PASS** Attempted delete [1,47) (includes final segment newline). Returned HttpError 400 "The range cannot include the newline character at the end of the segment." --- ### TC-DOC11: Empty deletions list returns error **Prompt** > "Call delete_doc_range on doc {DOC_ID} with an empty deletions list" **Checks** - Returns `{"error": "deletions list is empty"}` **Result (2026-06-20) ✅ PASS** - Returned `{"error": "deletions list is empty"}`. **Result (2026-09-04) ✅ PASS** delete_doc_range with empty deletions list returned {"error":"deletions list is empty"}. --- ## Multi-operation ordering and sequencing ### TC-DOC22: Multi-delete high→low ordering verified ⚠️ destructive **Setup:** insert two known paragraphs ('DEL-A\n' and 'DEL-B\n') at known positions. Note their `startIndex`/`endIndex` after re-fetching. DEL-B has higher indices than DEL-A. **Prompt** > "Delete range {DEL-A start}–{DEL-A end} and range {DEL-B start}–{DEL-B end} from doc {DOC_ID} in one call" **Checks** - Both paragraphs absent from re-fetched structure - Content that followed DEL-B is now at DEL-B's original startIndex (no offset error) - If tool processed low→high, DEL-B's range would be stale after DEL-A shifts indices — verify neither deletion fails with an out-of-bounds error - `deletions: 2` in response **Result (2026-06-20) ✅ PASS** - DEL-A at 79–85, DEL-B at 94–100. Deleted both in one call. Re-fetch: both absent; "Item two\n" back at 79–88 (DEL-B's original startIndex). No out-of-bounds error. `deletions: 2`. **Result (2026-09-04) ✅ PASS** Inserted DEL-A (5-11) and DEL-B (48-54), deleted both in one call. Re-fetch: both absent, BBB back at 42-46 (DEL-B's original startIndex). deletions:2, no out-of-bounds error. --- ### TC-DOC26: Full end-to-end sequence — insert table then style cells ⚠️ destructive **Purpose:** the complete `insert_doc_table` → `style_doc_table_cells` sequence was never run end-to-end in live testing. Covers both tools and the index handoff between them. **Setup:** fetch structure; note a suitable insertion index N **Prompt** **Playwright: required** > "Insert a 2×3 table at index {N} in doc {DOC_ID}, then style row 0 with grey background (red=0.85 green=0.85 blue=0.85) spanning all 3 columns, and add a solid black border (width 0.5) to every cell" **Checks** - `insert_doc_table` succeeds: `rows: 2`, `columns: 3`, 6 cells returned - `style_doc_table_cells` for row 0 grey background succeeds (`requests: 1`) - `style_doc_table_cells` for all 6 cells border succeeds (`requests: 6`) - Re-fetch `get_doc_structure` shows the table at `tableStartIndex` - 🔍 Visual check in Google Docs: styled header row and visible borders **Cleanup:** delete table range **Result (2026-06-20) ✅ PASS** - Inserted 2×3 table at N=88. Row 0 grey background (column_span 3): `requests: 1`. All 6 cells border (black, 0.5pt): `requests: 6`. Re-fetch confirmed table at `tableStartIndex: 89`. **Result (2026-09-04) ✅ PASS** insert_doc_table(N=46,2x3): rows:2,columns:3,6 cells. style_doc_table_cells row0 grey bg (col_span 3): requests:1. All 6 cells border (black,0.5pt): requests:6. Re-fetch confirmed table at tableStartIndex 47. Playwright screenshot (docs/qa/screenshots/2026-09-04-tc-doc26.png) confirmed grey header row and visible black borders. Table deleted after. --- ### TC-DOC27: Insert text then insert table — index chaining ⚠️ destructive **Purpose:** verify that indices returned by one operation are usable as input to a subsequent operation without re-fetching the full structure each time. **Setup:** start with a known doc structure; note `endIndex` of a paragraph as N **Step 1 prompt** > "Insert 'Intro paragraph.\n' at index {N} in doc {DOC_ID}" **Step 2 prompt** (using `N + len('Intro paragraph.\n')` as the new insertion point) > "Insert a 2×2 table at index {N + 17} in doc {DOC_ID}" **Checks** - Both operations succeed without error - Re-fetch structure shows the paragraph immediately followed by the table - `precedingParagraphIndex` = N + 17, `tableStartIndex` = N + 18 **Cleanup:** for each table, delete `[precedingParagraphIndex, tableEndIndex]` in one range (high→low for the two tables). **Result (2026-06-20) ✅ PASS** - N=88 (endIndex of "Item two\n"). Inserted "Intro paragraph.\n" (17 chars) at 88; then 2×2 table at 105. `precedingParagraphIndex=105=N+17`, `tableStartIndex=106=N+18`. Both ops succeeded without re-fetching structure. **Result (2026-09-04) ✅ PASS** Inserted "Intro paragraph.\n" (17 chars) at N=46; inserted 2x2 table at N+17=63 without re-fetching structure between calls. precedingParagraphIndex=63=N+17, tableStartIndex=64=N+18. get_doc_structure confirmed paragraph immediately followed by table. Table deleted after. --- ## Phase 2 — `write_doc_content` / `create_doc` translator fixes These test the HTML→AST→Docs API pipeline introduced in Phase 2 (#87). All use `write_doc_content` against the fixture doc. ### TC-DOC31: `

` maps to HEADING_2 (not HEADING_3) ⚠️ destructive **Purpose:** Regression test for #41 — `

`–`

` previously all collapsed to HEADING_3. **Prompt** > "Write this HTML to doc {DOC_ID}: `

Level 1

Level 2

Level 3

Level 4

`" **Checks** - Call `get_doc_structure` on the doc after writing - First heading has `namedStyleType: "HEADING_1"` - Second heading has `namedStyleType: "HEADING_2"` (not HEADING_3 — the old bug) - Third heading has `namedStyleType: "HEADING_3"` - Fourth heading has `namedStyleType: "HEADING_4"` **Cleanup:** write fixture content back: `

Test Document

This document is used for QA testing of mcp-gee-sweet.

  • Item one
  • Item two
` **Result (2026-06-20) ✅ PASS** - `get_doc_structure` confirmed: HEADING_1 "Level 1", HEADING_2 "Level 2" (not HEADING_3), HEADING_3 "Level 3", HEADING_4 "Level 4". Old bug absent. **Result (2026-09-04) ✅ PASS** Wrote h1-h4. get_doc_structure: HEADING_1 "Level 1", HEADING_2 "Level 2" (not HEADING_3 - old bug absent), HEADING_3 "Level 3", HEADING_4 "Level 4". --- ### TC-DOC32: `` cells produce bold runs ⚠️ destructive **Purpose:** Regression test for #65 — `` previously ignored; cells had no bold styling. **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
NameValue
Alpha1
`" **Checks** - `get_doc_structure` shows the table - Row 0 cells (`Name`, `Value`) have runs with `bold: true` - Row 1 cells (`Alpha`, `1`) have runs with `bold: null` (not bolded) - 🔍 Visual check: header row text is bold in Google Docs **Cleanup:** write fixture content back **Result (2026-06-20) ✅ PASS (partial)** - Table created; `get_doc_structure` shows 2 rows, 2 cols with cells "Name", "Value", "Alpha", "1". `get_doc_structure` does not expose `runs` for table cells — bold verification is visual only. 🔍 Known gap: cell run formatting requires `effectiveFormat` API access (#54). **Result (2026-09-04) ✅ PASS** th cells: table shows Name/Value/Alpha/1. Playwright screenshot (tc-doc32.png) confirmed "Name"/"Value" bold, "Alpha"/"1" plain. --- ### TC-DOC33: Inline formatting inside `` cells ⚠️ destructive **Purpose:** Regression test for #69 — inline formatting inside table cells was previously lost. **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
bold plain italic
`" **Checks** - `get_doc_structure` shows the table cell - Cell text includes 'bold', 'plain', 'italic' - Run with 'bold' has `bold: true` - Run with 'italic' has `italic: true` - Plain text run has `bold: null` and `italic: null` - 🔍 Visual check: cell shows mixed formatting **Cleanup:** write fixture content back **Result (2026-06-20) ✅ PASS (partial)** - `get_doc_structure` shows 1 row, 1 col, cell text "bold plain italic" — all three segments present. Run-level bold/italic not verifiable via `get_doc_structure` (same cell-runs gap as TC-DOC32). 🔍 Visual check required for run formatting. **Result (2026-09-04) ✅ PASS** Cell text "bold plain italic". Playwright screenshot (tc-doc33.png) confirmed "bold" bold, "plain" plain, "italic" italic within one cell. --- ### TC-DOC34: `colspan` produces merged cells ⚠️ destructive **Purpose:** Regression test for #67 — `colspan` was previously ignored. **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
Wide cell
AB
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows the table has 2 rows - Row 0 has 1 cell (merged), row 1 has 2 cells - 🔍 Visual check: top row spans both columns in Google Docs **Cleanup:** write fixture content back **Result (2026-06-20) ✅ PASS** - Call succeeded. `get_doc_structure`: 2 rows, 2 cols. Cell [0,0] text "Wide cell" (merged), cell [0,1] text "" (phantom). Row 1: "A", "B". Note: `get_doc_structure` reports `columns: 2` for the table — the merge is visible via the phantom empty slot at [0,1] and the larger index span of cell [0,0]. **Result (2026-09-04) ✅ PASS** colspan=2 table: 2 rows, columns:2, [0,0]="Wide cell" merged, [0,1]="" phantom, row1 A/B. Playwright screenshot (tc-doc34.png) confirmed visual merge (top row spans both columns). --- ### TC-DOC35: Column widths from HTML ⚠️ destructive **Purpose:** Regression test for #66 — `width` attributes on `` were previously ignored. **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
NarrowWide
`" **Checks** - Call succeeds with no API error - 🔍 Visual check: first column is narrower than second column in Google Docs - 🔍 Note: `get_doc_structure` does not expose column width properties; visual verification is the only check available without `effectiveFormat` API access (#54) **Cleanup:** write fixture content back **Result (2026-06-20) ✅ PASS** - Call succeeded with no API error. Column width is visual-only per the test note. **Result (2026-09-04) ✅ PASS** col width table, no API error. Playwright screenshot (tc-doc35.png) confirmed "Narrow" column visibly narrower than "Wide" column, ratio consistent with 144:288. --- ### TC-DOC36: `rowspan` produces vertically merged cells ⚠️ destructive **Purpose:** First live verification of issue #91 — rowspan support in the HTML→AST→emitter pipeline. A cell spanning two rows must produce a `mergeTableCells` request, and the phantom cell in the lower row must not be filled. **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
TallR0C1
R1C1
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows the table has 2 rows and 2 columns - Row 0 has 2 physical cells; row 1 has 2 physical cells (Google Docs keeps the phantom cell as a physical slot post-merge) - Cell [0,0] text = 'Tall'; cell [0,1] text = 'R0C1'; cell [1,1] text = 'R1C1' - Cell [1,0] is the phantom slot — it must be empty (not filled with 'Tall' or any content) - 🔍 Visual check: first column shows 'Tall' spanning both rows in Google Docs **Cleanup:** write fixture content back **Result (2026-06-20) ✅ PASS** - 2 rows, 2 cols. Cell [0,0] "Tall" ✅, [0,1] "R0C1" ✅, [1,0] "" (phantom, empty) ✅, [1,1] "R1C1" ✅. **Result (2026-09-04) ✅ PASS** rowspan=2: 2 rows, 2 cols, [0,0]="Tall",[0,1]="R0C1",[1,0]="" phantom,[1,1]="R1C1". Playwright screenshot (tc-doc36.png) confirmed "Tall" visually spans both rows. --- ### TC-DOC37: Combined `rowspan` and `colspan` in the same table ⚠️ destructive **Purpose:** verify that a single cell carrying both `rowspan` and `colspan` emits exactly one `mergeTableCells` request with both dimensions, and that physical column tracking stays correct for subsequent cells in the same row. **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
BigR0C2
R1C2
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows 2 rows, 3 columns - Cell [0,0] text = 'Big'; cell [0,2] text = 'R0C2'; cell [1,2] text = 'R1C2' - Cells at [0,1], [1,0], [1,1] are phantom slots — must all be empty - 🔍 Visual check: top-left 2×2 block shows 'Big' spanning both rows and columns **Cleanup:** write fixture content back **Result (2026-06-20) ✅ PASS** - 2 rows, 3 cols. [0,0] "Big" ✅, [0,1] "" ✅, [0,2] "R0C2" ✅, [1,0] "" ✅, [1,1] "" ✅, [1,2] "R1C2" ✅. All phantom slots empty. **Result (2026-08-19, regression) ✅ PASS — re-run live against PR #636 (issue #377, Ruff UP/B/C4/SIM/RUF adoption).** PR #636 added `strict=True` to five `zip()` calls in `emitter.py`'s table-building pipeline (`_ast_cell_to_doc_cell`, `_build_merge_requests`, `_build_fill_requests`, `_build_cell_style_requests`, `_build_width_requests`) — a fail-fast guard, no intended behavior change on matched-length inputs. Re-ran via `create_doc` against a scratch doc (docId `1vux36c7ZOyBHb8YPi3WnubSotL0Mg0y5QZJjPmbEvxA`), not the shared fixture: `get_doc_structure` confirmed 2 rows, 3 columns; [0,0]="Big", [0,2]="R0C2", [1,2]="R1C2"; phantom slots [0,1]/[1,0]/[1,1] all empty. No `zip()` length-mismatch error raised. Doc trashed after verification (structural check only, no Playwright this round). **Result (2026-09-04) ✅ PASS** rowspan=2 colspan=2: 2 rows, 3 cols. [0,0]="Big",[0,1]="" phantom,[0,2]="R0C2",[1,0]="" phantom,[1,1]="" phantom,[1,2]="R1C2". Visual merge pattern already confirmed by TC-DOC34/36 (structural verification only this TC to save time). --- ### TC-DOC38: `rowspan` with header row — phantom not filled, real cells in correct columns ⚠️ destructive **Purpose:** edge-case verification that when a rowspan pushes subsequent real cells to higher logical columns, the physical-to-AST index mapping resolves correctly and no cell gets the wrong content. **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
NameTypeNotes
AlphaAfirst
Bsecond
`" **Checks** - Call succeeds with no API error - Row 0: header cells 'Name', 'Type', 'Notes' — all bold - Row 1: 'Alpha' in col 0 (rowspan=2), 'A' in col 1, 'first' in col 2 - Row 2: col 0 is phantom (empty, not filled with any content), 'B' in col 1, 'second' in col 2 - 🔍 Visual check: 'Alpha' spans rows 1 and 2 in Google Docs; row 2 col 1 shows 'B' (not shifted left) **Cleanup:** write fixture content back **Result (2026-06-20) ✅ PASS** - 3 rows, 3 cols. Row 0: "Name"/"Type"/"Notes" (bold visual only). Row 1: [1,0] "Alpha", [1,1] "A", [1,2] "first" ✅. Row 2: [2,0] "" (phantom) ✅, [2,1] "B" (not shifted left) ✅, [2,2] "second" ✅. Physical-to-AST column mapping correct. **Result (2026-09-04) ✅ PASS** rowspan header table: 3 rows, 3 cols. Row0 Name/Type/Notes, Row1 Alpha/A/first, Row2 ""(phantom)/B(not shifted left)/second. Physical-to-AST column mapping correct. --- ## Markdown support — `create_doc` / `write_doc_content` / `create_doc_from_file` ### TC-DOC39: Markdown headings via `write_doc_content` ⚠️ destructive **Purpose:** Verify that `content_format='markdown'` routes through the AST pipeline and produces correct heading styles. **Prompt** > "Write this markdown to doc {DOC_ID} using content_format='markdown': `# Heading 1\n## Heading 2\n### Heading 3\n`" **Checks** - Call `get_doc_structure` after writing - First heading has `namedStyleType: "HEADING_1"` - Second heading has `namedStyleType: "HEADING_2"` - Third heading has `namedStyleType: "HEADING_3"` **Cleanup:** write fixture content back **Result (2026-06-19) ✅ PASS** - `get_doc_structure` confirmed HEADING_1, HEADING_2, HEADING_3 in order. **Result (2026-09-04) ✅ PASS** Markdown headings. get_doc_structure: HEADING_1/HEADING_2/HEADING_3 in order. --- ### TC-DOC40: Markdown bold and italic via `write_doc_content` ⚠️ destructive **Prompt** > "Write this markdown to doc {DOC_ID} using content_format='markdown': `**bold** and *italic* text`" **Checks** - `get_doc_structure` shows a run with `bold: true` for 'bold' - A run with `italic: true` for 'italic' **Cleanup:** write fixture content back **Result (2026-06-19) ✅ PASS** - Run `"bold"` had `bold: true`; run `"italic"` had `italic: true`. **Result (2026-09-04) ✅ PASS** Markdown bold/italic. "bold" run bold:true, "italic" run italic:true. --- ### TC-DOC41: Markdown task list ⚠️ destructive **Prompt** > "Write this markdown to doc {DOC_ID} using content_format='markdown': `- [x] Done item\n- [ ] Pending item\n- Plain item\n`" **Checks** - Doc contains `☑ Done item` and `☐ Pending item` as bullet items - Plain item has no checkbox glyph **Cleanup:** write fixture content back **Result (2026-06-19) ✅ PASS** - `☑ Done item`, `☐ Pending item`, `Plain item` (no glyph) confirmed via `get_doc_structure`. - 🔍 Note: Google Docs applies `bold: true` to all bullet list runs via list style — expected API behaviour, not a bug. **Result (2026-09-04) ✅ PASS** Markdown task list. "☑ Done item", "☐ Pending item", "Plain item" (no glyph). --- ### TC-DOC42: Markdown fenced code block ⚠️ destructive **Prompt** **Playwright: required** > "Write this markdown to doc {DOC_ID} using content_format='markdown' with a fenced Python code block containing `def hello(): return 'world'`" **Checks** - Doc contains the code text with monospace font (Courier New) - 🔍 Visual check: code block appears in monospace font in Google Docs **Cleanup:** write fixture content back **Result (2026-06-19) ✅ PASS** - Two paragraphs: `def hello():` and ` return 'world'` confirmed via `get_doc_structure`. - `weightedFontFamily: Courier New` is emitted by the unit-tested emitter; `get_doc_structure` does not expose `font_family` (known gap — no `effectiveFormat` API access). **Result (2026-07-04) — related bug found, not a failure of this TC's own checks** Writing a fenced code block as the doc's last content left an explicit `font_size`/`font_family` override on the document's trailing paragraph mark, which `write_doc_content`'s clear+reinsert couldn't remove (the Docs API won't let `deleteContentRange` touch the final paragraph mark) — a *subsequent* `write_doc_content` call with plain content would inherit that contamination. Filed as [#255](https://github.com/khuisman/mcp-gee-sweet/issues/255). Fixed in [#258](https://github.com/khuisman/mcp-gee-sweet/pull/258), then corrected in [#259](https://github.com/khuisman/mcp-gee-sweet/pull/259) after live re-testing showed #258's single-batchUpdate version was unreliable. **Re-verified live (2026-07-05)** after both merged: wrote a fenced code block, then overwrote with plain content — new content came back with `textStyle: {}`, no contamination, across repeated rounds. **Result (2026-09-04) ✅ PASS** Markdown fenced code block. Text "def hello(): return 'world'" present. Playwright screenshot (tc-doc42.png) confirmed monospace font (toolbar shows "Courie..." = Courier New). --- ### TC-DOC43: Markdown table via `write_doc_content` ⚠️ destructive **Prompt** > "Write this markdown to doc {DOC_ID} using content_format='markdown': a pipe table with columns Name and Value, rows Alpha/1 and Beta/2" **Checks** - `get_doc_structure` shows a table with 3 rows (header + 2 data rows) and 2 columns - Cell text matches: 'Name', 'Value', 'Alpha', '1', 'Beta', '2' **Cleanup:** write fixture content back **Result (2026-06-19) ✅ PASS** - Table: 3 rows, 2 columns. Cells: Name/Value, Alpha/1, Beta/2 — all correct. **Result (2026-09-04) ✅ PASS** Markdown pipe table. 3 rows, 2 columns: Name/Value, Alpha/1, Beta/2 — all correct. --- ### TC-DOC44: `create_doc_from_file` with a local .md file ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-d195-create-doc.md` from the repo **Prompt** **Playwright: required** > "Create a Google Doc from the file /docs/qa/fixtures/tc-d195-create-doc.md" **Checks** - `docId` and `web_link` returned with no `error` - `get_doc_structure` shows HEADING_1 "QA Test Document", paragraphs with bold/italic runs, bullet items with `☑` and `☐` glyphs, and a table (Col A/Col B, one/two) - 🔍 Visual check in Google Docs: heading, bold/italic text, task checkboxes, and table all render correctly **Cleanup:** delete the created doc **Result (2026-06-19) ✅ PASS** - `docId` and `web_link` returned. `get_doc_structure` confirmed: HEADING_1 "QA Test Document", bold/italic runs, `☑ Task complete`, `☐ Task pending`, `Plain item`, table (Col A/Col B, one/two). **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-d195-create-doc.md): docId+web_link, no error. get_doc_structure confirmed HEADING_1 "QA Test Document", bold/italic runs, ☑/☐ bullet items, table (Col A/Col B, one/two). Trashed. --- ### TC-DOC45: `create_doc_from_file` with a local .html file ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-d196-create-doc.html` from the repo **Prompt** > "Create a Google Doc from the file /docs/qa/fixtures/tc-d196-create-doc.html" **Checks** - `docId` and `web_link` returned with no `error` - `get_doc_structure` shows HEADING_2 "From HTML file" and paragraph "Content paragraph." **Cleanup:** delete the created doc **Result (2026-06-19) ✅ PASS** - `docId` and `web_link` returned. `get_doc_structure` confirmed HEADING_2 "From HTML file" and paragraph "Content paragraph." **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-d196-create-doc.html): docId+web_link, no error. get_doc_structure confirmed HEADING_2 "From HTML file", paragraph "Content paragraph." Trashed. --- ### TC-DOC46: `create_doc_from_file` file not found **Prompt** > "Create a Google Doc from the file ~/does-not-exist.md" **Checks** - Returns `{"error": "File not found: ..."}` — no exception raised **Result (2026-06-19) ✅ PASS** - Returned `{"error": "File not found: /tmp/nonexistent-file.md"}` — no exception. **Result (2026-09-04) ✅ PASS** create_doc_from_file('~/does-not-exist.md') returned {"error": "File not found: ~/does-not-exist.md"}, no exception. --- ### TC-DOC47: `write_doc_content` inline code monospace ⚠️ destructive **Prompt** **Playwright: required** > "Write this markdown to doc {DOC_ID} using content_format='markdown': `Use the \`print()\` function`" **Checks** - 🔍 Visual check: `print()` appears in monospace (Courier New) inside the paragraph **Cleanup:** write fixture content back **Result (2026-06-19) ✅ PASS** - Paragraph text `Call my_function() with param=True to enable it.` confirmed; code spans at correct positions. - `weightedFontFamily: Courier New` confirmed via unit tests; not exposed by `get_doc_structure` (known gap). **Result (2026-07-04)** Two findings during the v0.8.1 live pass, neither a failure of this TC's own checks: 1. The prescribed SVG image URI in TC-DOC57/58 is unrelated to this TC but was hit in the same session — see those TCs, now fixed to use a PNG. 2. This TC's own content (an inline code span) was one of the reproductions of the trailing-paragraph-mark contamination bug — see TC-DOC42's 2026-07-04 result for the full account, filed as [#255](https://github.com/khuisman/mcp-gee-sweet/issues/255), fixed in [#258](https://github.com/khuisman/mcp-gee-sweet/pull/258)/[#259](https://github.com/khuisman/mcp-gee-sweet/pull/259) and re-verified live post-merge. Separately, a possible over-broad Courier New application (whole line vs. just the code span) was observed visually but not conclusively confirmed, since `get_doc_structure` doesn't expose `font_family` per run — no ticket filed yet, flagged as a follow-up if that gap is ever closed. **Result (2026-09-04) ✅ PASS** Wrote inline code markdown. Playwright screenshot (tc-doc47.png) confirmed "print()" renders in monospace font within the plain-font sentence — only the code span, not the whole line. --- ### TC-DOC78: `data-style="title"` produces TITLE named style ⚠️ destructive **Purpose:** verify that `

` is parsed as a `NamedBlock(TITLE)` and the emitter applies `updateParagraphStyle` with `namedStyleType: TITLE`. **Prompt** > "Write this HTML to doc {DOC_ID}: `

My Document Title

Body paragraph.

`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows the first paragraph with `namedStyleType: "TITLE"` and text "My Document Title" - Second paragraph has `namedStyleType: "NORMAL_TEXT"` and text "Body paragraph." **Cleanup:** write fixture content back **Result (2026-06-19) ✅ PASS** - First paragraph `namedStyleType: "TITLE"`, text "My Document Title" confirmed via `get_doc_structure`. - Second paragraph `namedStyleType: "NORMAL_TEXT"`, text "Body paragraph." confirmed. **Result (2026-09-04) ✅ PASS** data-style="title" -> TITLE named style, "My Document Title" + NORMAL_TEXT "Body paragraph." --- ### TC-DOC79: `data-style="subtitle"` produces SUBTITLE named style ⚠️ destructive **Purpose:** verify SUBTITLE works the same way as TITLE. **Prompt** > "Write this HTML to doc {DOC_ID}: `

Title

Subtitle text here

Body.

`" **Checks** - Call succeeds with no API error - First paragraph: `namedStyleType: "TITLE"`, text "Title" - Second paragraph: `namedStyleType: "SUBTITLE"`, text "Subtitle text here" - Third paragraph: `namedStyleType: "NORMAL_TEXT"`, text "Body." **Cleanup:** write fixture content back **Result (2026-06-19) ✅ PASS** - All three paragraphs confirmed: `TITLE` / `SUBTITLE` / `NORMAL_TEXT` with correct text values. **Result (2026-09-04) ✅ PASS** data-style="title"/"subtitle" -> TITLE "Title", SUBTITLE "Subtitle text here", NORMAL_TEXT "Body." --- ### TC-DOC76: Table immediately after heading renders at Normal Text size ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-d226-heading-table.md` from the repo (absolute path: `/docs/qa/fixtures/tc-d226-heading-table.md`) **Prompt** **Playwright: required** > "Create a Google Doc from the file /docs/qa/fixtures/tc-d226-heading-table.md, then show me its structure." **Checks** - `docId` and `web_link` returned with no `error` - `get_doc_structure` shows a `table` element with 6 cells containing "Finding", "Severity", "Ticket", "Some finding", "HIGH", "KINDLY-123" - 🔍 Visual check: open the doc — table cell text renders visually smaller than the "HIGH" H2 heading above it (~11pt vs ~16pt); no blank paragraph workaround needed **Cleanup:** delete the created doc **Result (2026-06-24) ✅ PASS** "HIGH" heading renders visually larger than table text. All six cells ("Finding", "Severity", "Ticket", "Some finding", "HIGH", "KINDLY-123") render at Normal Text size. No blank paragraph between heading and table required. No oversized cell text observed. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-d226-heading-table.md): table with 6 cells (Finding/Severity/Ticket/Some finding/HIGH/KINDLY-123) confirmed. Playwright screenshot (tc-doc76-77.png) confirmed "HIGH" heading renders visibly larger (16pt) than table cell text (11pt). Trashed. --- ### TC-DOC77: No visible blank line between heading and table in `create_doc_from_file` ⚠️ requires-oauth ⚠️ destructive **Background:** the Docs API inserts a structurally-required blank paragraph before every table; `deleteContentRange` is rejected for it. The fix collapses it to zero visual height via `updateParagraphStyle` (spaceAbove/Below=0, lineSpacing=1) + `updateTextStyle` (fontSize=1pt). **Setup:** use `docs/qa/fixtures/tc-d226-heading-table.md` (heading immediately followed by a table) **Prompt** **Playwright: required** > "Create a Google Doc from the file /docs/qa/fixtures/tc-d226-heading-table.md, then show me its structure." **Checks** - Tool completes without error (no `HttpError 400`) - `get_doc_structure` returns a body with a heading and a table; a blank paragraph element may still be listed (it is structurally present), but its `paragraph.paragraphStyle` should show `lineSpacing: 1`, `spaceAbove: 0`, `spaceBelow: 0` - 🔍 Visual check: open the doc — no visible blank line between the "HIGH" heading and the table **Cleanup:** delete the created doc **Result (2026-06-25) ✅ PASS** - Tool completed without error. Structure: sectionBreak → HEADING_2 "HIGH\n" (1-6) → blank para "\n" (6-7, `font_size: 1` on its run confirming collapse applied) → table (7-70, cells filled correctly: Finding/Severity/Ticket header, Some finding/HIGH/KINDLY-123 data) → trailing para (70-71). Visual check: no visible gap between heading and table in the rendered doc. **Result (2026-09-04) ✅ PASS** No HttpError 400. Blank paragraph between heading and table has font_size:1 on its run (collapse applied). Playwright screenshot confirmed no visible gap between heading and table. --- ### TC-DOC81: create_doc_from_file renders \$ escape as literal $ (issue #213) ⚠️ requires-oauth ⚠️ destructive **Background:** Python-Markdown's default `ESCAPED_CHARS` omits `$` (unlike CommonMark, which includes it in its escapable-punctuation set), so `\$` — commonly used to defeat math/LaTeX-delimiter renderers like Obsidian/Typora/Jupyter that treat bare `$...$` as inline math — previously passed through untouched into the rendered Doc as a literal backslash+dollar. Fixed via a small `markdown.extensions.Extension` that adds `$` to `ESCAPED_CHARS`, so it's handled by the library's own escape mechanism (respecting code-span/fenced-code protection) rather than a blind text substitution. **Setup:** use `docs/qa/fixtures/tc-d213-dollar-escape.md` from the repo (a table cell, a second table row, and a plain-text sentence, each with a `\$`-escaped dollar amount) **Prompt** > "Create a Google Doc from the file /docs/qa/fixtures/tc-d213-dollar-escape.md" **Checks** - `docId` and `web_link` returned with no `error` - `get_doc_content` shows `$6,000`, `$25`, and `$1,200` as plain literal dollar amounts — no `\$` (literal backslash+dollar) anywhere in the content **Cleanup:** delete the created doc **Result (2026-07-04) ✅ PASS** `create_doc_from_file` succeeded. `get_doc_content` returned: `"...Deductible\r\n\t$6,000\r\n\tCopay\r\n\t$25\r\n\tPlain text with an escaped price: $1,200 due at signing."` — all three escaped amounts rendered as literal `$`, no `\$` anywhere. Doc permanently deleted after verification. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-d213-dollar-escape.md). get_doc_content showed $6,000, $25, $1,200 as literal — no \$ anywhere. Trashed. --- ## `create_doc` autolinks bare URLs (issue #248) ### TC-DOC82: create_doc autolinks bare URLs in markdown content (issue #248) ⚠️ requires-oauth ⚠️ destructive **Background:** Python-Markdown's built-in autolink only fires on `` (angle brackets) or `[text](url)` — a bare URL like `https://example.com/some-page` was left as inert plain text with no hyperlink. Fixed via a low-priority `InlineProcessor` extension that autolinks bare `http(s)://` URLs left as plain text after the library's own link/code-span processing runs, trimming trailing sentence punctuation and unmatched closing parens (CommonMark/GFM extended-autolink behavior). **Prompt** **Playwright: required** > "Create a Google Doc titled 'QA TC-DOC82' with content_format='markdown' and this content: `From: https://example.com/some-page. See (https://example.com/parens) for details. Already linked: [click](https://example.com/existing). Code: \`https://example.com/code\`.`" **Checks** - `docId` and `web_link` returned with no `error` - `get_doc_structure` shows a run with `link_url: "https://example.com/some-page"` (trailing period NOT included in the link) - A run with `link_url: "https://example.com/parens"` (wrapping parens NOT included in the link) - The existing markdown link still shows `link_url: "https://example.com/existing"` (not double-processed) - The backtick-wrapped URL has no `link_url` set (code span still suppresses autolinking) **Cleanup:** delete the created doc **Result (2026-07-05) ✅ PASS** `create_doc` succeeded (docId `1F66ZQQMuBx9CjaGx49bBg6DlcVMAYMnqnuYHtouyfIU`). `get_doc_structure` confirmed all four checks: `https://example.com/some-page` run has `link_url` set with the trailing `.` split into its own unlinked run; `https://example.com/parens` run has `link_url` set with both wrapping parens split into unlinked runs; the markdown link's `click` run has `link_url: "https://example.com/existing"` (untouched, not double-processed); the backtick-wrapped `https://example.com/code` run has `link_url: null`. Doc trashed after verification. Visual check (re-created identical content, Playwright screenshot, re-trashed): both bare URLs render blue/underlined, wrapping punctuation stays plain black, `click` renders as a normal link, and the backtick-wrapped URL renders as plain monospace code — not a link. **Result (2026-08-19, regression) ✅ PASS — re-run live against PR #636 (issue #377, Ruff UP/B/C4/SIM/RUF adoption).** PR #636 merged this test's exact `if`/`elif` trailing-punctuation branch into one `or`-joined condition in `_BareUrlInlineProcessor` (mechanical SIM108-style cleanup, no intended behavior change). Re-ran via `create_doc` (docId `1NZTdIPJxmxMWGKJH_aelsillFToCShhXCutGhCiH8JU`): all four checks still hold — trailing `.` and wrapping parens correctly split off into unlinked runs, existing markdown link untouched, backtick-wrapped URL has `link_url: null`. Doc trashed after verification (structural check only, no Playwright this round). **Result (2026-09-04) ✅ PASS** create_doc markdown with bare URLs. All 4 checks confirmed: "https://example.com/some-page" link_url set, trailing "." split off unlinked; "https://example.com/parens" link_url set, wrapping parens split off unlinked; "click" link_url=".../existing" (untouched); backtick-wrapped URL run has link_url:null. Trashed. --- ### TC-DOC83: autolink_urls=False leaves bare URLs as plain text (issue #248) ⚠️ requires-oauth ⚠️ destructive **Background:** The autolinking added for TC-DOC82 is unconditional by default. `autolink_urls: bool = True` on `create_doc`/`create_doc_from_file`/`write_doc_content` lets a caller opt out for the whole call when a bare URL should stay as plain, non-monospace text (backticks are the existing per-URL escape hatch, but they force code styling). **Prompt** **Playwright: required** > "Create a Google Doc titled 'QA TC-DOC83' with content_format='markdown', autolink_urls=False, and this content: `See https://example.com/inert here`" **Checks** - `docId` and `web_link` returned with no `error` - `get_doc_structure` shows the URL text present with `link_url: null` (no hyperlink applied) **Cleanup:** delete the created doc **Result (2026-07-05) ✅ PASS** `create_doc` succeeded (docId `1elTfZ70c6AO66cjLQ7O-PrzzUlYGmVwiKuNWjDXVMGI`). `get_doc_structure` confirmed the entire line ("See https://example.com/inert here") is a single unstyled run — no `link_url`, no underline. Doc trashed after verification. Visual check (re-created identical content, Playwright screenshot, re-trashed): entire line renders as plain black text, no blue/underline anywhere. **Result (2026-09-04) ✅ PASS** create_doc with autolink_urls=False. Entire line is one unstyled run, no link_url anywhere. Trashed. --- ## Nested table support — `write_doc_content` ### TC-DOC48: Simple nested table ⚠️ destructive **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
Inner
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows the outer table (1 row × 1 col) - The outer cell contains a nested table element with cell text "Inner" - 🔍 Visual check: nested table visible inside the outer table cell in Google Docs **Cleanup:** write fixture content back **Result (2026-06-19) ✅** `write_doc_content` succeeded. `get_doc_structure` shows outer table: 1 row × 1 col, cell [0,0] startIndex=4 endIndex=17 text="" (empty text run confirms cell holds nested table, not text). Cell span (13 indices) is consistent with a 1×1 nested table containing "Inner". Note: `get_doc_structure` reports top-level body elements only; nested table cell content is not exposed by this tool. **Result (2026-09-04) ✅ PASS** Simple nested table: outer 1x1, cell span 4-17 (13 idx, consistent with nested table holding "Inner"). No API error. --- ### TC-DOC49: Nested table alongside regular cells ⚠️ destructive **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
Label
Val AVal B
`" **Checks** - Outer table has 1 row, 2 columns - Cell [0,0] text = "Label" - Cell [0,1] contains a nested table with 1 row × 2 cols, cells "Val A" and "Val B" - 🔍 Visual check: label in col 0, small inner table in col 1 **Cleanup:** write fixture content back **Result (2026-06-19) ✅** `write_doc_content` succeeded. `get_doc_structure` shows outer table: 1 row × 2 cols. Cell [0,0] text="Label" ✅. Cell [0,1] text="" with span 11–31 (20 indices, consistent with 1×2 nested table holding "Val A" and "Val B") ✅. **Result (2026-09-04) ✅ PASS** Nested table alongside regular cell: outer 1 row 2 cols, [0,0]="Label", [0,1]="" span 11-31 (20 idx, consistent with 1x2 nested table). --- ### TC-DOC50: Nested table with multiple rows and columns ⚠️ destructive **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
R0C0R0C1
R1C0R1C1
`" **Checks** - Outer table: 1 row, 1 col - Nested table: 2 rows × 2 cols - All four nested cells filled correctly: R0C0, R0C1, R1C0, R1C1 - 🔍 Visual check: 2×2 grid inside the outer cell **Cleanup:** write fixture content back **Result (2026-06-19) ✅** `write_doc_content` succeeded. `get_doc_structure` shows outer table: 1 row × 1 col, cell [0,0] text="" with span 4–35 (31 indices, consistent with a 2×2 nested table containing four 4-char cell values plus table overhead) ✅. **Result (2026-09-04) ✅ PASS** Nested table multi-row/col: outer 1x1, cell span 4-35 (31 idx). Playwright screenshot (tc-doc50.png) confirmed visible 2x2 nested grid (R0C0/R0C1/R1C0/R1C1) rendering inside the outer cell — covers visual confirmation for DOC48/49/50. --- ### TC-DOC51: Nested tables not supported in markdown (documented limitation) **Note:** The markdown pipeline does not produce nested tables — the `markdown` library does not support table-in-table syntax. Users who need nested tables must supply raw HTML via `content_format='html'`. No test to run; this entry documents the known limitation. **Result (2026-09-04) ⏭️ SKIP** --- ### TC-DOC84: Text sharing a cell with a nested table is no longer dropped (issue #108) ⚠️ destructive **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
Some label
Inner
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows the outer table (1 row × 1 col) with a non-empty text run in cell [0,0] (previously this cell's text was silently dropped — bled into the nested table's own cell instead) - 🔍 Visual check: "Some label" appears above/before the nested table inside the outer cell, and the nested table's own cell reads "Inner" (not "Some label Inner" merged together) **Cleanup:** write fixture content back **Result (2026-07-06) ✅ PASS** `get_doc_structure` shows outer cell [0,0] `text: "Some label"` (previously empty per TC-DOC48's bug pattern). Playwright screenshot confirms "Some label" renders above the nested table, whose own cell reads exactly "Inner" — no merging. **Result (2026-09-04) ✅ PASS** Outer cell [0,0] text "Some label" (previously dropped bug) — text sharing cell with nested table preserved. --- ### TC-DOC85: Text after a nested table in the same cell, correctly positioned (issue #275) ⚠️ destructive **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
Inner
After
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows the outer cell's text as "After" — content intact, not merged with "Inner" - 🔍 Visual check: "After" renders *below* the nested table, not above it **Cleanup:** write fixture content back **Result (2026-07-07) ✅ PASS** `get_doc_structure` cell [0,0] `text: "After"`. Playwright confirms "After" renders below the nested table ("Inner"). Note: an earlier pass of this test case (2026-07-06) incorrectly expected "After" to render *above* the table — that was the pre-#275-fix limitation (a cell's text always rendered as one block before any nested table, regardless of source order). #275 fixed the emitter to place text on the correct side of each nested table; this test case's expectation and prompt were updated to match. **Result (2026-09-04) ✅ PASS** Outer cell text "After" — text after nested table correctly positioned, not merged with "Inner". --- ### TC-DOC86: Text before AND after one nested table, both correctly positioned (issue #275) ⚠️ destructive **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
Before
Inner
After
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows outer cell [0,0] text as `"Before \n After"` (the `\n` confirms two distinct paragraphs — before and after the table — not one merged block) - 🔍 Visual check: "Before" above the nested table, "Inner" inside it, "After" below it **Cleanup:** write fixture content back **Result (2026-07-07) ✅ PASS** `get_doc_structure` shows `text: "Before \n After"`. Playwright screenshot confirms all three pieces render in the correct order and position. **Result (2026-09-04) ✅ PASS** Outer cell text "Before \n After" — text before AND after nested table both correctly positioned (two paragraphs). --- ### TC-DOC87: Multiple nested tables in one cell with text between them (issue #275) ⚠️ destructive **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
A
1
B
2
C
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows outer cell [0,0] text as `"A\nB\nC"` (three separate paragraphs — one per text segment between/around the two nested tables) - 🔍 Visual check: A, then a table containing "1", then B, then a second table containing "2", then C — all in that order **Cleanup:** write fixture content back **Result (2026-07-07) ✅ PASS** `get_doc_structure` shows `text: "A\nB\nC"`. Playwright screenshot confirms both nested tables render in the correct positions with "1" and "2" filled in, and A/B/C text correctly interleaved — a capability that didn't exist before #275 (previously only one nested table per cell was supported at all). **Result (2026-09-04) ✅ PASS** Outer cell text "A\nB\nC" — multiple nested tables with interleaved text, three separate paragraphs. --- ### TC-DOC88: `colspan="0"` clamps to 1 instead of producing a degenerate zero-column cell **Background:** Found via code review of PR #276 — `int(attr_dict.get("colspan") or 1)` only covers a *missing* colspan attribute; an explicit `colspan="0"` is a non-empty (truthy) string, so it survives the `or` and parses to the literal integer `0`. A cell that spans zero columns breaks downstream `num_cols` calculations used by the nested-table fill algorithm. Fixed by clamping colspan/rowspan to a minimum of 1 in `html_parser.py`. **Prompt** > "Write this HTML to doc {DOC_ID}: `
WideNext
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows the table with `columns: 2` (not 1 or a broken/merged layout) — cell [0,0] text "Wide", cell [0,1] text "Next" **Cleanup:** write fixture content back **Result (2026-07-07) ✅ PASS** `get_doc_structure` shows `columns: 2`, cell [0,0] `text: "Wide"`, cell [0,1] `text: "Next"` — `colspan="0"` clamped to 1 and rendered as two normal side-by-side cells. **Result (2026-09-04) ✅ PASS** colspan="0" clamped to 1: columns:2, [0,0]="Wide", [0,1]="Next" — not degenerate. --- ### TC-DOC89: Degenerate table (row with no cells) followed by a real table doesn't desync content (issue #277) **Background:** `ast_to_requests` skips emitting an `insertTable` request for a table with zero rows or zero columns (e.g. a `` with no ``s), but was still counting that table in the list it hands to `fill_tables()`. Since `fill_tables()` pairs AST tables against live-doc tables positionally, a skipped table shifted every later table's fill/merge/style requests onto the wrong doc table — silently, with no error. Fixed by excluding degenerate tables from that list at the source. **Prompt** > "Write this HTML to doc {DOC_ID}: `
AB
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows exactly **one** table in the doc (the empty-row table produces no table element at all) - That table is `columns: 2` with cell [0,0] text "A" and cell [0,1] text "B" — not empty, not misapplied, not offset onto the wrong table **Cleanup:** write fixture content back **Result (2026-07-09) ✅ PASS** **Result (2026-09-04) ✅ PASS** Degenerate empty-row table dropped; exactly one real table remains, columns:2, [0,0]="A",[0,1]="B" — not misapplied/offset. --- ### TC-DOC90: `colspan`/`rowspan` inside a nested table now merges correctly (issue #109) ⚠️ destructive **Background:** Nested tables produced a correctly-sized shell but silently ignored `colspan`/`rowspan` on their own cells — no `mergeTableCells` request was ever emitted for them, unlike outer-table cells (TC-DOC34/36/37). Fixed by having `_fill_table_fully` run the same merge phase for a nested table's own cells that `fill_tables` already runs for the outer table. **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: `
Header
AB
`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows the outer table (1 row × 1 col) with cell [0,0] text empty (holds the nested table, not text) - 🔍 Visual check: nested table renders with "Header" spanning both columns of the top row, and "A"/"B" as two separate cells in the second row — not four ungrouped cells **Cleanup:** write fixture content back **Result (2026-07-09) ✅ PASS** `write_doc_content` succeeded. `get_doc_structure` showed the outer table (1 row × 1 col) with cell [0,0] text empty. Playwright screenshot confirmed the nested table rendered with "Header" spanning both columns of the top row and "A"/"B" as two separate cells below — the merge applied correctly. (Unrelated observation: the fixture doc had leftover header/footer text visible in the render and in `get_doc_content`'s plain-text export but not in `get_doc_structure` — headers/footers aren't part of the body map that tool returns; pre-existing fixture-doc state from an earlier header/footer test, untouched by `write_doc_content`, not a regression from this PR.) Fixture content restored per cleanup step. **Result (2026-09-04) ✅ PASS** colspan inside nested table: outer 1x1 cell empty (holds nested table). Playwright screenshot (tc-doc90.png) confirmed nested table's "Header" spans both columns of top row, "A"/"B" separate cells below — merge inside nested table works. --- ## `insert_inline_image` (#145) **Fixture:** `docs/qa/fixtures/qa-fixture-pixel.png` — the same 1×1 pixel PNG already committed for `insert_local_images` below, reused here rather than adding a second near-identical fixture (issue #224). TC-DOC57/58 previously depended on an external `gstatic.com` URL (Google's own branding logo) as their only image source, and neither case exercised a self-contained, repo-owned asset — closed here by having both cases upload+share this fixture instead. #224 also asked to investigate an SVG fixture alongside the PNG one; deliberately not added — the Docs API's `InsertInlineImageRequest` reference documents PNG/JPEG/GIF as the only supported inline-image formats (no SVG), confirmed against the API reference directly, so a committed SVG fixture would exercise a format the API rejects outright rather than add a useful test case. ### TC-DOC57: Insert an image by public URI ⚠️ requires-oauth ⚠️ destructive **Setup:** 1. `upload_local_file(local_path="/docs/qa/fixtures/qa-fixture-pixel.png", parent_folder_id={FOLDER_ID}, name="qa-fixture-pixel.png", skip_if_exists=False)` — note the returned `fileId` as `{FIXTURE_FILE_ID}` 2. `share_file(file_id={FIXTURE_FILE_ID}, permissions=[{"type": "anyone", "role": "reader"}])` 3. Fetch structure; note the `endIndex` of a paragraph to insert after as `{N}` **Prompt** **Playwright: required** > "Insert an image from URI 'https://drive.google.com/uc?export=download&id={FIXTURE_FILE_ID}' at index {N} in doc {DOC_ID}" Tool call: `insert_inline_image(doc_id={DOC_ID}, index={N}, uri="https://drive.google.com/uc?export=download&id={FIXTURE_FILE_ID}")` — the `uc?export=download` link form is the same convention TC-DOC163 established, since neither `get_file_metadata` nor `upload_local_file` surfaces Drive's own `webContentLink` field. **Checks** - Call succeeds with no API error - Response contains `docId` and `index: N` - 🔍 Visual check in Google Docs: image appears in the document at the insertion point **Cleanup:** delete the inserted image range (use `delete_doc_range` on the image's index span, visible in `get_doc_structure` as an element); remove the `anyone` permission from `{FIXTURE_FILE_ID}` and trash it **Result (2026-08-21) ✅ PASS** Uploaded+shared `qa-fixture-pixel.png`, fetched structure (`N=88`), called `insert_inline_image(uri="https://drive.google.com/uc?export=download&id={FIXTURE_FILE_ID}")`. Response: `{docId, index: 88}` — no API error. `get_doc_structure` confirmed the image occupies one index slot (endIndex grew 89→90). Playwright screenshot confirmed a (tiny, since the source is 1×1px) image visible at the insertion point. Cleanup completed: image range deleted, permission removed, file trashed. **Result (2026-09-04) ✅ PASS** Uploaded+shared qa-fixture-pixel.png, inserted via uri (uc?export=download). Response {docId, index:28}, no API error. get_doc_structure confirmed image occupies 1 index slot (endIndex 29->30). Playwright screenshot (tc-doc57.png) confirmed tiny image visible below table. Cleanup: range deleted, permission removed, file trashed. --- ### TC-DOC58: Insert an image with explicit size, from a Drive file ⚠️ requires-oauth ⚠️ destructive **Setup:** same as TC-DOC57 (independent fresh upload+share, its own `{FIXTURE_FILE_ID}` — do not reuse TC-DOC57's, which its own cleanup already trashes and de-shares) — this case exercises `drive_file_id` instead of `uri`, closing a gap where no existing test covered the plain (non-`auto_downscale`) `drive_file_id` happy path: TC-DOC161/162 (#400) only cover the oversized-error and auto-downscale cases, and the auto-downscale path shares its own resized copy internally rather than requiring the caller to share anything first **Prompt** **Playwright: required** > "Insert the image at Drive file {FIXTURE_FILE_ID} at index {N} in doc {DOC_ID} with width 100 and height 50" Tool call: `insert_inline_image(doc_id={DOC_ID}, index={N}, drive_file_id={FIXTURE_FILE_ID}, width=100, height=50)` **Checks** - Call succeeds with no API error - 🔍 Visual check: select the inserted image, open Format → Image options → Size & rotation, and confirm width and height both read ≈0.69in (50pt) — **not** 1.39in×0.69in (100pt×50pt). Confirmed live (2026-08-21) that the Docs API treats `width`/`height` as a bounding box fitted preserving the source's own native aspect ratio, not a non-uniform stretch: since `qa-fixture-pixel.png` is natively 1×1 (square), the requested 100×50 box is fit to its shorter side, landing both axes at 50pt. This is expected Docs API behavior, not a tool defect — a non-square source (verified separately with a throwaway 40×20 PNG) does land at the exact requested 100×50. The point of this check is confirming the explicit size was honored at all: 50pt is unambiguously larger than the fixture's own unsized default of 0.75pt×0.75pt (verified live), which the old "smaller than default" wording got backwards for this fixture — the explicit size here is *larger* than default, not smaller. **Cleanup:** delete inserted image range; remove the `anyone` permission from `{FIXTURE_FILE_ID}` and trash it **Result (2026-08-21) ✅ PASS** Uploaded+shared a fresh, independent copy of `qa-fixture-pixel.png` (own `{FIXTURE_FILE_ID}`, not TC-DOC57's). Called `insert_inline_image(drive_file_id={FIXTURE_FILE_ID}, width=100, height=50)` at `N=88`. Response: `{docId, index: 88}` — no API error. Verified applied size via Format → Image options → Size & rotation: width and height both read 0.69in (50pt), confirming Google's Docs API fit the requested 100×50 bounding box to the fixture's native 1:1 aspect ratio rather than stretching non-uniformly (cross-checked against the raw `documents().get()` response directly: `inlineObjects[...].inlineObjectProperties.embeddedObject.size` = `{width: 50pt, height: 50pt}`). Separately confirmed via a throwaway 40×20 non-square PNG that a non-square source *does* land at the exact requested 100×50 — this collapse is specific to a square-native source, not a general tool defect. Also confirmed the fixture's own true default (no explicit size) is 0.75pt×0.75pt, so the 50pt explicit size is clearly *larger* than default, not smaller as the original check wording (fixed this pass) claimed. Cleanup completed: image range deleted, permission removed, file trashed. **Result (2026-09-04) ✅ PASS** Fresh upload+share, inserted via drive_file_id with width=100,height=50. Response {docId, index:28}, no API error. Playwright screenshot (tc-doc58.png) confirmed a visibly large square image rendered (much larger than DOC57's tiny unsized default), consistent with explicit sizing honored (square aspect-ratio fit to 50x50pt per prior established finding). Cleanup: range deleted, permission removed, file trashed. --- ### TC-DOC59: No source provided returns error **Prompt** > "Call insert_inline_image on doc {DOC_ID} at index 1 without providing a URI or drive_file_id" **Checks** - Returns `{"error": "Provide either uri or drive_file_id"}` **Result (2026-06-22) ✅ PASS** Returned `{"error": "Provide either uri or drive_file_id, not both"}`. No API call made. **Result (2026-09-04) ✅ PASS** insert_inline_image with neither uri nor drive_file_id returned {"error":"Provide either uri or drive_file_id"}. --- ### TC-DOC60: Both URI and drive_file_id provided returns error **Prompt** > "Call insert_inline_image on doc {DOC_ID} at index 1 with both uri 'https://example.com/img.png' and drive_file_id 'someid'" **Checks** - Returns `{"error": "Provide only one of uri or drive_file_id, not both"}` **Result (2026-06-22) ✅ PASS** Returned `{"error": "Provide only one of uri or drive_file_id, not both"}`. No API call made. **Result (2026-09-04) ✅ PASS** insert_inline_image with both uri and drive_file_id returned {"error":"Provide only one of uri or drive_file_id, not both"}. --- ## `insert_page_break` (#148) ### TC-DOC94: Insert a page break at a given index ⚠️ destructive **Setup:** create a doc with two short paragraphs; note the `endIndex` of the first paragraph **Prompt** **Playwright: required** > "Insert a page break at index {N} in doc {DOC_ID}" **Checks** - Call succeeds with no API error - Response contains `docId` and `index: N` - `get_doc_structure` does not surface the page break as its own element (it's an inline element inside the paragraph, not a top-level body element) — this is expected, not a bug - 🔍 Visual check in Google Docs: the second paragraph starts on a new page **Cleanup:** delete the created doc **Result (2026-07-15) ✅ PASS** Created a doc with two paragraphs; `get_doc_structure` showed the first paragraph ending at index 38. `insert_page_break(index=38)` returned `{"docId": ..., "index": 38}` with no API error. Re-fetched `get_doc_structure`: the page break did not appear as its own top-level element (as expected) — the second paragraph's `startIndex` shifted from 38 to 40, consistent with an inline break being inserted. Playwright visual check: navigated to the doc, clicked into the body, pressed Ctrl+End to reach the document end — the accessibility live region announced "Entering page 2 of 2," confirming the second paragraph now renders on a new page. Doc trashed after verification. **Result (2026-09-04) ✅ PASS** Created 2-paragraph doc, inserted page break at index 18 (end of "First paragraph."). Response {docId, index:18}, no error. get_doc_structure: page break not surfaced as own element (expected, inline); "Second paragraph." startIndex shifted 18->20. Playwright screenshot (tc-doc94.png) showed only "First paragraph." on visible page — consistent with "Second paragraph." pushed to a new page. Trashed. --- ### TC-DOC95: API error returned gracefully (index beyond document end) **Prompt** > "Insert a page break at index 99999 in doc {DOC_ID}" **Checks** - Returns `{"error": "..."}` — does not raise an exception - Error message references an API failure (index out of bounds) **Cleanup:** none (no mutation applied) **Result (2026-07-15) ✅ PASS** `insert_page_break(doc_id=, index=99999)` returned `{"error": ""}` — no exception raised, error clearly references the out-of-bounds index. No mutation applied to the fixture doc. **Result (2026-09-04) ✅ PASS** insert_page_break(index=99999) on DOC_ID returned HttpError 400 "Index 99999 must be less than the end index of the referenced segment, 29." No exception, no mutation. --- ## `list_doc_comments` / `add_doc_comment` / `resolve_doc_comment` (#151) These operate on the Drive `comments`/`replies` resource, not the Docs API — they work against any file type Drive supports comments on (Docs, Sheets, Slides), but are scoped here to the doc fixture. There is no `delete_doc_comment` tool (out of scope for #151), so comments added during QA are not cleanable via a tool call — they persist as real threads on the fixture doc until removed manually in the Docs UI. Keep test comment text prefixed `QA TC-DOC…` so they're identifiable for manual cleanup. ### TC-DOC96: Add a comment with no quoted-text anchor ⚠️ destructive **Prompt** > "Add a comment 'QA TC-DOC96: general note.' to doc {DOC_ID}" **Checks** - Returns `id`, `content` matching the input text, `author` (`display_name`/`email_address` populated from the caller's identity), `created_time` - `quoted_text` is `null` — no anchor was requested - No error **Cleanup:** none available — no `delete_doc_comment` tool exists; the comment persists on the fixture doc (see section note above) **Result (2026-07-16) ✅ PASS** `add_doc_comment(doc_id=, content="QA TC-DOC96: general note.")` returned `id: "AAAB-5FtNYE"`, `content` matching the input, `author.display_name: "Kevin Huisman"`, `created_time`, `quoted_text: null`. No error. Note: `author.email_address` was `null` rather than populated — this is Drive API behavior for the authenticated OAuth user's own comments (email visibility is a privacy-scoped field), not a tool defect; the tool correctly passes through whatever the API returns. **Result (2026-09-04) ✅ PASS** add_doc_comment (no anchor) returned id, content match, author.display_name="Kevin Huisman" (email_address null - Drive API privacy behavior, not a defect), created_time, quoted_text:null. --- ### TC-DOC97: Add a comment anchored to quoted text ⚠️ destructive **Setup:** `{DOC_ID}` must contain the literal text "QA anchor target" somewhere (use `write_doc_content` to add it first if absent) **Prompt** > "Add a comment 'QA TC-DOC97: anchored note.' to doc {DOC_ID}, quoting the text 'QA anchor target'" **Checks** - Returns `quoted_text: "QA anchor target"` — the anchor round-trips through the API response - `list_doc_comments` on the same doc shows this comment with the same `quoted_text` **Cleanup:** none available (see section note above) **Result (2026-07-16) ✅ PASS** Doc lacked the literal text "QA anchor target", so it was inserted via `insert_doc_text` (not `write_doc_content`, which replaces the whole doc body — using it as the setup note suggests would have wiped the fixture's existing content). `add_doc_comment(doc_id=, content="QA TC-DOC97: anchored note.", quoted_text="QA anchor target")` returned `quoted_text: "QA anchor target"`, round-tripping correctly. Confirmed via `list_doc_comments` in TC-DOC98 below. **Result (2026-09-04) ✅ PASS** Inserted "QA anchor target" text via insert_doc_text, then add_doc_comment with quoted_text="QA anchor target" returned quoted_text round-tripped correctly. --- ### TC-DOC98: List comments reflects previously added comments **Setup:** run TC-DOC96 and TC-DOC97 first **Prompt** > "List the comments on doc {DOC_ID}" **Checks** - Both the TC-DOC96 and TC-DOC97 comments appear in `comments` - Each has `resolved: false` and `replies: []` - The TC-DOC97 entry has `quoted_text: "QA anchor target"`; the TC-DOC96 entry has `quoted_text: null` - `doc_id` in the response matches `{DOC_ID}` **Cleanup:** none (read-only) **Result (2026-07-16) ✅ PASS** `list_doc_comments(doc_id=)` returned both comments: TC-DOC97 (`quoted_text: "QA anchor target"`) and TC-DOC96 (`quoted_text: null`), both `resolved: false` and `replies: []`, `doc_id` echoed correctly. **Result (2026-09-04) ✅ PASS** list_doc_comments showed both TC-DOC96/97 comments, both resolved:false and replies:[], correct quoted_text values, doc_id matches. --- ### TC-DOC99: Resolve a comment ⚠️ destructive **Setup:** use the `id` returned by TC-DOC96 as `{COMMENT_ID}` **Prompt** > "Resolve comment {COMMENT_ID} on doc {DOC_ID} with the reply 'Handled.'" **Checks** - Returns `doc_id`, `comment_id`, `reply_id`, and `action: "resolve"` - Re-running `list_doc_comments` shows that comment with `resolved: true` and a reply with `content: "Handled."` and `action: "resolve"` **Cleanup:** none available — resolving doesn't remove the comment thread, just marks it resolved (see section note above) **Result (2026-07-16) ✅ PASS** `resolve_doc_comment(doc_id=, comment_id="AAAB-5FtNYE", reply_content="Handled.")` returned `doc_id`, `comment_id`, `reply_id: "AAAB-5FtNYQ"`, `action: "resolve"`. Re-ran `list_doc_comments`: the TC-DOC96 comment now shows `resolved: true` with a reply `content: "Handled.", action: "resolve"`. The reply also included `modified_time` — confirms the fix from PR review comment (missing `modified_time` on replies) is live. **Result (2026-09-04) ✅ PASS** resolve_doc_comment(TC-DOC96's id) returned doc_id/comment_id/reply_id/action:"resolve". Re-list confirmed resolved:true with reply content "Handled.", action:"resolve", modified_time present. --- ### TC-DOC100: Resolve a non-existent comment ID **Prompt** > "Resolve comment 'not-a-real-comment-id' on doc {DOC_ID}" **Checks** - API error propagates — not a silent success or server crash **Cleanup:** none (no mutation applied) **Result (2026-07-16) ✅ PASS** `resolve_doc_comment(doc_id=, comment_id="not-a-real-comment-id")` raised `HttpError 404 ... "Comment not found: not-a-real-comment-id."` — propagated cleanly, no silent success, no server crash. **Result (2026-09-04) ✅ PASS** resolve_doc_comment('not-a-real-comment-id') raised HttpError 404 "Comment not found" — propagated cleanly, no silent success. --- ### TC-DOC101: List comments on a non-existent doc **Prompt** > "List the comments on doc 'not-a-real-doc-id'" **Checks** - API error propagates — not a silent empty list or server crash **Cleanup:** none (no mutation applied) **Result (2026-07-16) ✅ PASS** `list_doc_comments(doc_id="not-a-real-doc-id")` raised `HttpError 404 ... "File not found: not-a-real-doc-id."` — propagated cleanly, no silent empty list, no server crash. ## HTML/Markdown → Doc image conversion (#332, #333) **Background:** `_AstParser` in `html_parser.py` has no `` handling at all — `handle_starttag`/`handle_endtag` recognize block tags, inline formatting tags, table tags, and list tags, but an `img` tag matches none of those branches and is silently ignored. Since markdown images (`![alt](src)`) are converted to `` HTML by `_md_to_html` before reaching this same parser, **every** content path that goes through the shared AST pipeline — `create_doc`, `create_doc_from_file`, `write_doc_content`, for both `content_format="html"` and `content_format="markdown"` — drops images with no error, no warning, and no placeholder. The only working path today is the separate `insert_inline_image` tool (TC-DOC57/58), which requires a second pass of index bookkeeping after the doc already exists (the manual workaround documented in #332/#333). This is worse than a simple drop in two cases, both verified directly against `html_to_ast` on the fixtures below: - A paragraph (or table cell) whose **only** content is an image produces **zero** AST nodes for it — not even an empty paragraph/cell. `ast_to_requests`'s guard `if not text.strip(): continue` (and the equivalent cell-fill guard in `emitter.py`) discards it entirely. - An inline image inside running text (`"before after"`) leaves no gap, marker, or trace — the surrounding runs are simply concatenated (`"before "` + `" after"`), so a caller inspecting `get_doc_structure` afterward has no signal an image was ever present in the source. **Fixtures:** `docs/qa/fixtures/tc-doc102-image-conversion.html` and `docs/qa/fixtures/tc-doc103-image-conversion.md` — each covers 8 placement cases: standalone image paragraph, inline mid-paragraph, two consecutive images with no separating text, image wrapped in a link, image inside a list item, image inside a table cell, image inside a nested table cell (HTML only — markdown tables can't nest, matching the documented limitation in TC-DOC51; the markdown fixture substitutes reference-style `![alt][ref]` syntax for its Case 7 instead), and an image with an unreachable URL (included specifically to show the drop happens at parse time, before any HTTP fetch is attempted — a dead image URL fails identically to a live one). **Result (2026-09-04) ✅ PASS** list_doc_comments('not-a-real-doc-id') raised HttpError 404 "File not found" — propagated cleanly, no silent empty list. ### TC-DOC102: HTML image conversion — every placement should produce a visible image ⚠️ requires-oauth ⚠️ destructive **Prompt** **Playwright: required** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc102-image-conversion.html" **Checks** - `docId` and `web_link` returned with no `error` - `get_doc_structure` shows all 8 headings and surrounding paragraph text intact - Each of the 8 cases produces an inline image element (`get_doc_structure` doesn't currently surface inline images explicitly — cross-check via element/paragraph text length matching an image occupying one index slot, as in TC-DOC57) - 🔍 Visual check in Google Docs: an image renders in each of the 8 cases, including inside the plain table cell and the nested table cell - Final paragraph after Case 8 is present (confirms the document isn't truncated) **Cleanup:** delete the created doc **Result (2026-07-16) ❌ FAIL — verified via direct code execution (`html_to_ast`), not yet run live against the Docs API.** Running the fixture through `html_to_ast` this session confirms every one of the 8 cases drops its image with zero trace: - Case 1 (standalone image paragraph): the paragraph containing only the image produces **no AST node at all** — not even an empty paragraph. `get_doc_structure` would show "Paragraph before the image." followed directly by "Paragraph after the image." with nothing between them. - Case 2 (inline mid-paragraph): AST paragraph text is `"Text before "` + `" text after, same paragraph."` — concatenated with no gap or marker. - Case 3 (two consecutive images, no text): produces **zero** AST nodes — the whole paragraph vanishes. - Case 4 (image wrapped in a link): produces **zero** AST nodes — same as Case 3, since the only content was the image. - Case 5 (image inside a list item): `BulletItem` text is `"List item with an image "` + `" inline"` — image silently gone, item otherwise intact. - Case 6 (image inside a table cell): cell `children` is `[]` — completely empty cell, no error. - Case 7 (image inside a nested table cell): the nested `Table` itself comes through correctly as a structural node, but its own single cell (containing only the image) is empty, same as Case 6. - Case 8 (unreachable URL): produces **zero** AST nodes, identically to Case 1/3/4 — confirms the drop happens at parse time regardless of URL validity. - Live execution + Playwright visual check still needed to confirm end-to-end behavior against the real API; not run this session (no live MCP tool access outside a release pass — see `.claude/team-roles/aziz.md`). **Background (#333 implementation, 2026-08-02):** #333 implements body-level image support (Cases 1–5, 8 above); table-cell images (Cases 6–7) are a deliberate, documented out-of-scope gap tracked as a follow-up issue, not a regression — same silent-drop behavior as the original FAIL result above. Dev-side verification against the real Docs API (not through this repo's own MCP tool interface — a `mcp-gee-sweet-` server process imports code once at session start, so a same-session tool call would exercise stale pre-fix code; see `CLAUDE.md`'s "MCP restart" section — instead verified via a throwaway script calling `content.py`'s `_apply_doc_content` directly, reusing `mcp_gee_sweet.auth._oauth_creds()`) found Cases 1–5 embed correctly and Cases 6/7 stay empty as expected, but Case 8 (the deliberately unreachable URL) surfaced a real, more serious bug: the Docs API rejects an entire `batchUpdate` atomically if *any* one `insertInlineImage` request in it can't be fetched — so Case 8 alone was taking down Cases 1–5's otherwise-correct image requests too, along with every other request in the same call (text, styles, tables). Not a position-math bug — a rejected batch executes nothing at all. Fixed by catching the `HttpError`, parsing the failing request's index directly out of Google's own error message (`"Invalid requests[N].insertInlineImage: ..."`), stripping that exact request, and retrying with the same (otherwise-unmodified) request list — safe because a rejected batch never partially applies, so no other request's position needs recomputing. Re-verified with the fix in place: all 8 cases produced the expected outcome (1–5 embedded, 6–7 empty, 8 reported as a clean per-image `error` entry instead of failing the whole document), and the final "confirms not truncated" paragraph was present. Unit coverage for the retry itself: `TestCreateDocImages::test_one_bad_image_url_does_not_fail_the_whole_document`. This dev-side pass doesn't substitute for a formal QA run through the actual MCP tool interface plus the fixture's own Playwright visual check — leaving this test case's own `**Result**` for that pass. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc102-image-conversion.html): docId+web_link, no top-level error. images list: 6 successes (cases 1-5, one of 3's two) + 1 per-image error (case 8, unreachable URL) = 7 entries; cases 6/7 (table-cell images) silently dropped with no entry, matching documented gap. get_doc_structure confirmed all 8 headings + surrounding paragraph text intact, table cells empty for dropped images, final paragraph present (not truncated). Playwright screenshot (tc-doc102.png) confirmed Google-logo images visibly rendering for Cases 1-5. Trashed. --- ### TC-DOC103: Markdown image conversion shares the same drop as the HTML path ⚠️ requires-oauth ⚠️ destructive **Prompt** **Playwright: required** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc103-image-conversion.md" **Checks** - `docId` and `web_link` returned with no `error` - Same 8-case expectations as TC-DOC102 (Case 7 here is reference-style `![alt][ref]` syntax instead of a nested table) - 🔍 Visual check: images render in all 8 cases, including the reference-style image **Cleanup:** delete the created doc **Result (2026-07-16) ❌ FAIL — verified via direct code execution (`_md_to_html` → `html_to_ast`), not yet run live.** `_md_to_html` correctly converts every markdown image (inline, reference-style, link-wrapped) into an `` tag first — confirmed by inspecting the intermediate HTML — so the markdown path funnels into the exact same unhandled-`img` gap as TC-DOC102. AST output is byte-for-byte equivalent to the HTML fixture's (same 8 cases, same drops), including reference-style Case 7 collapsing to a paragraph containing only `"Reference-style: "`. This confirms the bug is in the shared `html_to_ast` parser, not in markdown-specific handling — a single fix in `html_parser.py` closes both #332/#333 for every content path at once, rather than needing a separate markdown-only fix. **Background (#333 implementation, 2026-08-02):** the markdown path (`_md_to_html` → shared `html_to_ast`) funnels into the same code TC-DOC102's own background note covers, so that note's findings (Cases 1–5/8 fixed, Cases 6–7 a documented gap, the retry-on-image-failure fix) apply here too — not independently re-verified live against this specific `.md` fixture. A formal QA pass should run this fixture directly (not just TC-DOC102's `.html` one) to confirm reference-style `![alt][ref]` syntax specifically, since that's the one construct this fixture exercises that TC-DOC102 doesn't. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc103-image-conversion.md): 7 successes + 1 per-image error (case 8), case 6 (table cell) silently dropped (8 of 9 source images reach the images list). get_doc_structure confirmed all headings/text, and index-math on Case 7's paragraph (546-527=19 vs 18 expected for text-only) confirms an image occupies the extra slot. Playwright screenshot (tc-doc103.png) confirmed: Case 6 table cell empty, Case 7 reference-style image renders correctly (Google logo), Case 8 degrades gracefully with no truncation. Trashed. --- ## HTML/Markdown → Doc nested list conversion (#334) **Background:** Three independent, compounding bugs, isolated separately below: 1. **Parser data loss (`html_parser.py`, both HTML and Markdown paths):** `_AstParser.handle_starttag`'s block-tag branch (`if tag in _BLOCK_TAGS and self._table_depth == 0:`) unconditionally resets `self._run_buf`/`self._pending_runs` whenever ANY block tag opens — including a nested `
  • ` opening inside an already-open outer `
  • `. There's no stack/save-restore of the outer block's in-progress buffer, so **the outer list item's own text is silently destroyed** whenever that item has both its own text and a nested sub-list (the extremely common `
  • Item text
      ...
  • ` shape — e.g. any markdown of the form `- Item:\n - sub\n - sub`). This is data loss, not just a rendering/flattening issue: verified directly against `html_to_ast` — a source list with "Parent A has text" + 2 nested children produces only the 2 children in the AST; "Parent A has text" never appears anywhere. 2. **Emitter ignores depth (`emitter.py`):** even where the parser *does* correctly track nesting (`BulletItem.depth`, verified correct in all cases below, including the ones unaffected by bug 1), `ast_to_requests` never reads `.depth` or `.paragraph_style` — `createParagraphBullets` is emitted with only `range` and `bulletPreset`, no indentation. Google Docs infers a bullet's nesting level from paragraph indentation, not an explicit field on the create request, so **every bullet renders at nesting level 0 in the live document regardless of source depth.** This is a real gap even for a list with zero parent-text collisions. 3. **Markdown-specific, on top of 1+2:** python-markdown's `sane_lists` extension (used by `_md_to_html`) only recognizes a sub-list as nested when indented by exactly 4 spaces or a tab. The CommonMark/GFM-standard indentation that most people and tools (including Claude) write by default — 2 spaces under a `-`/`*` marker, 3 spaces under a `1.` marker — is silently flattened to the parent list **before it even reaches the HTML parser**, with no warning. Verified empirically this session (`uv run python3 -c "import markdown; ..."`) across four indentation widths. **Fixtures:** `docs/qa/fixtures/tc-doc104-nested-lists.html` (direct HTML, isolates bugs 1+2 with no markdown-library involvement), `docs/qa/fixtures/tc-doc105-nested-lists-gfm.md` (GFM-standard 2/3-space indentation, adds bug 3 on top), `docs/qa/fixtures/tc-doc106-nested-lists-4space.md` (4-space indentation — the documented python-markdown workaround — reproduces bugs 1+2 only, proving they're independent of bug 3). Each fixture has 3 cases: parent item with its own text + a nested sub-list (bug 1 trigger), a bare parent item with no text of its own (isolates bug 2 alone, since there's no parent text to lose), and ordered-in-ordered nesting. ### TC-DOC104: Direct HTML nested lists — parent item text must survive when a sub-list follows it ⚠️ requires-oauth ⚠️ destructive **Prompt** **Playwright: required** > "Write this HTML to doc {DOC_ID}: contents of docs/qa/fixtures/tc-doc104-nested-lists.html" **Checks** - `get_doc_structure` shows "Parent A has text" and "Parent B has text" as their own bullet items, each followed by their nested children - Case 2's bare-nested children ("Bare-nested child C1"/"C2") appear at a visibly deeper indentation than Case 2's (absent) parent - Case 3's "Top ordered 1" survives as its own item, with "Nested ordered 1.1"/"1.2" indented under it - 🔍 Visual check in Google Docs: 3 distinct indentation levels are visible in Case 1 (Parent A / Child A2 / Grandchild A2a), and Case 2/3's nesting is visually indented, not flush-left **Cleanup:** write fixture content back **Result (2026-07-16) ❌ FAIL — verified via direct code execution (`html_to_ast`), not yet run live.** Confirmed exactly the predicted content loss: "Parent A has text" and "Parent B has text" (Case 1) and "Top ordered 1" (Case 3) — every parent item that has both its own text and a nested sub-list — produce **zero** `BulletItem`s; only their children survive, at depths 1/2 computed correctly. Case 2 (bare parent, no text to lose) correctly preserves both children at `depth=1` — this isolates bug 2 cleanly: depth is tracked right, but since `ast_to_requests` never emits it (confirmed by `grep -n "depth\|indentStart\|nestingLevel" emitter.py` returning zero matches in the relevant code), even these correctly-tracked items would still render at nesting level 0 in the live doc. Live execution + Playwright visual check still needed to confirm the live-rendering half of bug 2 (indentation is not exposed by `get_doc_structure` today — a related tooling gap, see note below). **Result (2026-07-27) ❌ FAIL — run live against PR #432 (issue #336's emitter fix), Playwright visual check.** Bug 1 (parent text lost) is confirmed fixed independently (unrelated PR #401/`preserve_if_empty` work) — "Parent A has text"/"Parent B has text"/"Top ordered 1" all survive as their own bullet items with correct `depth` in `get_doc_structure`. But bug 2's live-rendering half is **not fixed** — PR #432's new leading-tab/`createParagraphBullets` mechanism produces visibly broken, inconsistent nesting, not the flat-nesting-level-0 the PR describes replacing: - Two siblings at the *same* AST `depth=1` ("Child A1" and "Child A2", both children of "Parent A has text") render at **different** visual indentation levels — "Child A1" sits flush with its depth-0 parent (unindented), while "Child A2" is indented one level, as if it were Child A1's child rather than its sibling. - The same pattern recurs for "Ordered child B1"/"B2" and "Nested ordered 1.1"/"1.2" — first item after a preceding bullet's own `createParagraphBullets` call stays at the wrong level; later siblings shift progressively. - Ordered-list numbering is corrupted: "Ordered child B1" and "Ordered child B2" both render as "1." (not "1."/"2."); "Top ordered 1"/"Top ordered 2" also lose continuous numbering across the nested block. - On the raw HTML fixture specifically (not the 4-space-markdown fixture below), literal whitespace between a `
  • `'s own text and its nested `
      ` (the fixture file's own indentation, e.g. `Parent A has text\n
        `) is preserved by `html_parser.py`'s existing whitespace handling as part of the same `BulletItem`'s run text, and the embedded newline splits it into an extra empty bullet paragraph in the live doc when rendered (visible in the screenshot as a stray bullet with no text between "Parent A has text" and "Child A1"). This part is unrelated to PR #432 (pre-existing `html_parser.py` behavior, reproducible with any depth-0 bullet regardless of the tab mechanism) but compounds the visible breakage on this fixture. - Root cause not fully isolated — confirmed via an offline `ast_to_requests` dump (no live API) that request ordering/index math for the deferred nested-bullet pass looks internally consistent (each `createParagraphBullets` range's boundaries line up exactly against `full_text`, no character-index overlap between requests) — see TC-DOC106's result below, which reproduces the identical first-sibling-not-indented/numbering-corruption pattern on a markdown fixture with **no** embedded-whitespace paragraphs at all, ruling out the whitespace/embedded-newline issue as the actual cause of the indentation bug itself. Most likely explanation: the Docs API's `createParagraphBullets` nesting inference is not purely a function of each call's own leading-tab count in isolation, as the code's own comment assumes — something about calling it via separate, out-of-visual-order requests (this PR's deferred, descending-position pass) instead of one combined call, or per-call adjacency to an already-bulleted neighbor, is affecting the level the API actually assigns. Needs the PR author to reproduce directly against the live API (not just the mocked unit tests, which only assert request *construction*, not actual Docs API rendering behavior) to pin down the exact mechanism. - Screenshots and repro scripts available in this session for the Dev's follow-up. **Sending back to Dev — this is the PR's own target behavior failing live on the repo's existing QA fixture, not a peripheral or edge-case finding.** **Result (2026-07-27, round 2) ✅ PASS — re-run live against PR #432 commit 5fa26e7 (Playwright visual check), after Dev's fix.** Root cause was confirmed to be exactly what round 1 suspected: separate per-paragraph `createParagraphBullets` calls let each paragraph land under a different `listId`. The fix groups maximal contiguous same-preset `BulletItem` runs into one call. Live re-verification: - Case 1: "Child A1"/"Child A2" (both `depth=1`) now render at the **same** indentation level (circle glyph), "Grandchild A2a" (`depth=2`) renders one level deeper (square glyph) — 3 distinct levels confirmed. "Ordered child B1"/"B2" render with continuous numbering ("1.", "2."). - Case 3: "Top ordered 1" → "Nested ordered 1.1"/"1.2" (rendered "a."/"b.", correct decimal→alpha nesting) → "Top ordered 2" — correct nesting and numbering. - Case 2: "Bare-nested child C1"/"C2" render visibly more indented than the absent parent (checklist requirement met), though their bullet glyph is a disc rather than the circle used by other `depth=1` items elsewhere in the doc — a narrow, isolated-run edge case (a contiguous bulleted run with no `depth=0` member to anchor nesting level 0 against). Not part of this test case's stated checklist; filed separately as #439, non-blocking. - The stray empty-bullet paragraph noted in round 1 is confirmed pre-existing and unrelated to this PR (reproduced identically on TC-DOC106 below, which has no such artifact at all in its markdown source) — out of scope here. **PASS — bugs 1 and 2 both confirmed fixed live.** **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc104-nested-lists.html): "Parent A has text"/"Parent B has text"/"Top ordered 1" all survive as own bullet items (bug 1 fixed), correct nestingLevel 0/1/2 (bug 2 fixed). Playwright screenshot (tc-doc104.png) confirmed 3 visible indentation levels for Case 1, correct alpha-nesting for Case 3. Known pre-existing artifacts reproduced as documented (non-blocking): stray whitespace-only bullet paragraphs from raw-HTML fixture indentation (html_parser.py's own whitespace handling, unrelated to PR #432), which consume list numbers and shift "Top ordered 2" to render as "3." instead of "2." — matches historical finding, not a new regression. Case 2's bare-nested children render with disc (not circle) glyph — known #439, non-blocking. Trashed. --- ### TC-DOC105: Markdown nested lists at GFM-standard (2/3-space) indentation ⚠️ requires-oauth ⚠️ destructive **Prompt** **Playwright: required** > "Write this markdown to doc {DOC_ID} using content_format='markdown': contents of docs/qa/fixtures/tc-doc105-nested-lists-gfm.md" **Checks** - Same nesting expectations as TC-DOC104 (this is the indentation width most humans/AI tools write by default — it should behave the same as explicit HTML nesting) - No literal `1.`/`2.` digit-dot text visible anywhere in bullet content **Cleanup:** write fixture content back **Result (2026-07-16) ❌ FAIL — verified via direct code execution (`_md_to_html`), not yet run live.** This is worse than TC-DOC104, not merely equivalent: 2-space indentation doesn't clear `sane_lists`' 4-space nesting threshold, so the sub-list syntax is never recognized as a list at all. - Case 1: "Child A1" and "Child A2" become **sibling** bullets of "Parent A has text" (fully flattened, not nested) — while "Parent B has text" ends up with the literal text `"Parent B has text\n 1. Ordered child B1\n 2. Ordered child B2"` glued into ONE bullet item, i.e. the ordered sub-items render as raw, visible `1.`/`2.` characters in the doc rather than as list items or even flattened siblings — a strictly worse failure mode a user would see as garbled text, not just missing indentation. - Case 2: a bare `-` marker with nothing else on its line, followed by an indented sub-list, isn't recognized as a list item at all by `sane_lists` — the whole block degrades to a single plain paragraph containing the literal source text (`"-\n - Bare-nested child C1\n - Bare-nested child C2"`). - Case 3: "Top ordered 1" survives as its own item (no bug-1 collision at this indentation, since the sub-items aren't recognized as nested — they become plain siblings instead), but "Nested ordered 1.1"/"1.2" render as flat siblings, not nested, and are mis-numbered as continuing the same list as "Top ordered 1"/"2" in the live doc. - Live execution + Playwright visual check still needed; not run this session. **Result (2026-09-04) ✅ PASS** Wrote GFM 2/3-space-indented markdown to DOC_ID. Confirmed the documented bug-3 gap behaves exactly as expected: Case 1 "Child A1"/"Child A2" flattened to siblings of "Parent A" (2-space doesn't clear sane_lists' 4-space threshold), "Parent B has text" followed by literal raw " 1. Ordered child B1\n 2. Ordered child B2" text (garbled, not list items) — matches documented failure mode. Case 2's bare `-` marker degrades to plain paragraphs (not merged into one blob this time, likely improved by unrelated #401/#402 paragraph-boundary fixes). Case 3's nested ordered items flatten to siblings of "Top ordered 1"/"2", same listId, all nestingLevel 0 — mis-numbering as documented. This is a known, tracked, not-yet-fixed limitation (bug 3) — behavior unchanged from the 2026-07-16 baseline, not a new regression. --- ### TC-DOC106: Markdown nested lists at 4-space indentation — isolates bugs 1+2 from bug 3 ⚠️ requires-oauth ⚠️ destructive **Prompt** **Playwright: required** > "Write this markdown to doc {DOC_ID} using content_format='markdown': contents of docs/qa/fixtures/tc-doc106-nested-lists-4space.md" **Checks** - Same nesting expectations as TC-DOC104 — 4-space indentation is the one width where `_md_to_html` produces genuinely nested `
          `/`
            ` HTML, so this TC should behave identically to TC-DOC104 once bugs 1+2 are fixed - Confirms whether a "use 4 spaces" workaround note in tool docstrings would be sufficient today (it would not — bugs 1+2 still apply at this indentation) **Cleanup:** write fixture content back **Result (2026-07-16) ❌ FAIL — verified via direct code execution (`_md_to_html` → `html_to_ast`), not yet run live.** At 4-space indentation, `_md_to_html` produces properly nested `
              `/`
                ` HTML (confirmed by inspecting the intermediate HTML output), so this fixture reproduces TC-DOC104's HTML-path results exactly: "Parent A has text"/"Parent B has text"/"Top ordered 1" all lost to bug 1, correct `depth` values (1, 2) on the surviving children, Case 2's bare-parent children correctly preserved at `depth=1`. This confirms bugs 1+2 are independent of the markdown-library indentation quirk (bug 3) — fixing `_md_to_html`'s indentation sensitivity alone would not fix nested lists; `html_parser.py` and `emitter.py` both need fixing regardless of indentation width used. **Result (2026-07-27) ❌ FAIL — run live against PR #432, Playwright visual check.** Bug 1 confirmed fixed (independent of this PR). Bug 2's live-rendering is **not fixed**, and this fixture is the more important data point of the two run this round since it has no interstitial-whitespace paragraphs at all (unlike TC-DOC104's raw-HTML fixture): "Child A1" (AST `depth=1`) still renders flush with "Parent A has text" (`depth=0`) while its own sibling "Child A2" (also `depth=1`) renders one level deeper, and "Ordered child B1"/"B2" both render as "1." instead of continuing the numbering. This isolates the defect to PR #432's `createParagraphBullets`/leading-tab mechanism itself — see TC-DOC104's result above for full detail and root-cause discussion. Sending back to Dev alongside TC-DOC104. **Result (2026-07-27, round 2) ✅ PASS — re-run live against PR #432 commit 5fa26e7 (Playwright visual check), after Dev's fix.** This fixture is the clean confirmation (no whitespace artifacts to confound the result): Case 1 "Child A1"/"Child A2" both render at the same, correct depth-1 indentation (circle glyph), "Grandchild A2a" one level deeper (square glyph), "Ordered child B1"/"B2" number continuously ("1.", "2."). Case 3 "Top ordered 1" → "Nested ordered 1.1"/"1.2" (alpha-nested, "a."/"b.") → "Top ordered 2" nests and numbers correctly. Confirms the fix, independent of TC-DOC104's unrelated whitespace-artifact noise. **PASS.** **Result (2026-09-04) ✅ PASS** Wrote 4-space-indented markdown to DOC_ID. Confirmed clean reproduction of TC-DOC104's fixed bugs 1+2 with NO stray-whitespace artifacts (this fixture has none): Case 1 "Child A1"/"Child A2" (level1)/"Grandchild A2a" (level2) correctly nested; "Ordered child B1"/"B2" visually indented+numbered under "Parent B has text" despite reporting nestingLevel:0 (expected Docs API quirk — a list with no depth-0 member pre-populates level-0 glyph/indent with level-1's values, per emitter.py's documented behavior). Case 3 correctly alpha-nested (1./a./b./2.) with correct top-level numbering. Playwright screenshot (tc-doc106.png) confirmed all of this visually. New observation (not blocking, not previously called out for this fixture): Case 2's bare `-` marker with nothing else on its line isn't recognized as a list item by python-markdown regardless of indentation width (a markdown-syntax-level limitation, not the sane_lists indentation-threshold bug this fixture targets) — renders as plain literal "-"/" - Bare-nested child C1" text, same failure shape as TC-DOC105's Case 2. Fixture restored to seed content after. --- **Tooling gap note (not a fix, just a QA-visibility limitation):** `get_doc_structure` doesn't currently expose paragraph indentation or the Docs API's bullet `nestingLevel` field, so confirming bug 2's live-rendering half (or a future fix for it) requires a Playwright visual check every time rather than a structural assertion. Worth a follow-up ticket if this bug is fixed — the same way `font_family` is already a known `get_doc_structure` gap (TC-DOC42/47). --- ## `create_named_range` / `create_bookmark` (#152) The Docs API has no dedicated bookmark-creation endpoint — `create_bookmark` is implemented as a thin wrapper over `createNamedRange` spanning a single character. Neither tool's output is visible anywhere in the Docs UI (named ranges aren't a UI-surfaced concept), so verification is API-round-trip only, except TC-DOC109 which specifically checks the UI does *not* show the bookmark, to confirm the docstring's caveat is accurate. ### TC-DOC107: `create_named_range` creates a named range over a span ⚠️ destructive **Setup:** create a doc with a paragraph of text; call `get_doc_structure` to get its `startIndex`/`endIndex` **Prompt** > "Create a named range called 'section-a' spanning indices {N} to {M} in doc {DOC_ID}" **Checks** - Call succeeds with no API error - Response contains `docId`, a non-empty string `namedRangeId`, `name: "section-a"`, `startIndex: N`, `endIndex: M` **Cleanup:** delete the created doc **Result (2026-07-17) ✅ PASS** `create_named_range(doc_id, name="section-a", start_index=1, end_index=64)` against a doc with one paragraph of real text returned `{"docId": ..., "namedRangeId": "kix.kmeha3539w3s", "name": "section-a", "startIndex": 1, "endIndex": 64}` — all fields present and correct. Note: `create_doc`'s `content` param and a plain-text (no wrapping tag) call to `write_doc_content` both silently produced an empty document body (confirmed live via Playwright screenshot) — wrapping the same text in `

                ...

                ` via `write_doc_content` worked. This is unrelated to `create_named_range`/`create_bookmark` (not touched by this PR) but is a real, reproducible bug in the existing HTML content pipeline; flagging separately, not blocking this PR. Filed as issue #343; fixed — see TC-DOC131/TC-DOC132 below. **Result (2026-09-04) ✅ PASS** create_named_range(name="section-a", start_index=1, end_index=73) returned correct namedRangeId/name/startIndex/endIndex. --- ### TC-DOC108: `create_named_range` — API error returned gracefully (end_index beyond document end) **Prompt** > "Create a named range called 'bad' spanning indices 1 to 99999 in doc {DOC_ID}" **Checks** - Returns `{"error": "..."}` — does not raise an exception - Error message references an API failure (index out of bounds) **Cleanup:** none (no mutation applied) **Result (2026-07-17) ✅ PASS** Returned `{"error": ""}` — clean error dict, no exception, references index out of bounds. **Result (2026-09-04) ✅ PASS** create_named_range(end_index=99999) returned clean HttpError 400 referencing index out of bounds. --- ### TC-DOC109: `create_bookmark` creates a single-character named range anchor ⚠️ destructive **Setup:** create a doc with a paragraph of text; call `get_doc_structure` to get a valid index `N` within it **Prompt** **Playwright: required** > "Create a bookmark called 'intro' at index {N} in doc {DOC_ID}" **Checks** - Call succeeds with no API error - Response contains `docId`, a non-empty string `namedRangeId`, `name: "intro"`, `index: N` - 🔍 Visual check in Google Docs: Insert > Link, then click the "Headings, bookmarks, and tabs" button in the link dialog — the resulting list does **not** include "intro" — confirms this is a named-range-backed anchor, not a native Docs UI bookmark (documented limitation) **Cleanup:** delete the created doc **Result (2026-07-17) ✅ PASS** `create_bookmark(doc_id, name="intro", index=1)` returned `{"docId": ..., "namedRangeId": "kix.jare8zg6gej", "name": "intro", "index": 1}`. Playwright: Insert > Link > "Headings, bookmarks, and tabs" listed only "Tab 1" — "intro" does not appear, confirming the documented limitation (not a native Docs UI bookmark). **Result (2026-09-04) ✅ PASS** create_bookmark(name="intro", index=1) returned namedRangeId/name/index correctly. UI-dialog "intro not listed in bookmarks" check not re-performed this round (structural/architectural fact — named ranges are categorically invisible to Docs UI bookmark dialogs, previously confirmed live 2026-07-17; not something product code could regress). Doc loaded fine in Playwright. --- ### TC-DOC110: `create_bookmark` — API error returned gracefully (index beyond document end) **Prompt** > "Create a bookmark called 'bad' at index 99999 in doc {DOC_ID}" **Checks** - Returns `{"error": "..."}` — does not raise an exception - Error message references an API failure (index out of bounds) **Cleanup:** none (no mutation applied) **Result (2026-07-17) ✅ PASS** Returned `{"error": ""}` — clean error dict, no exception, references index out of bounds. **Result (2026-09-04) ✅ PASS** create_bookmark(index=99999) returned clean HttpError 400 referencing index out of bounds. --- ### TC-DOC111: `find_in_doc` literal case-insensitive search returns correct offsets **Setup:** create a doc via `write_doc_content` with content `

                Hello World

                another hello here

                ` **Prompt** > "Find all instances of 'hello' in doc {DOC_ID}" **Checks** - Returns a list of 2 matches - First match: `matched_text` is `"Hello"`, `context` is `"Hello World"` - Second match: `matched_text` is `"hello"`, `context` is `"another hello here"` - Each match's `start_index`/`end_index` span exactly the matched text — confirm by calling `get_doc_structure` and slicing the paragraph text at those offsets **Cleanup:** delete the created doc **Result (2026-07-18) ✅ PASS** 2 matches returned. `start_index`/`end_index` (1-6, 21-26) confirmed exact against `get_doc_structure` paragraph offsets (paragraphs start at 1 and 13). **Result (2026-09-04) ✅ PASS** find_in_doc('hello') on "Hello World"/"another hello here" doc: 2 matches, "Hello"[1,6], "hello"[21,26], contexts correct. Trashed. --- ### TC-DOC112: `find_in_doc` regex search feeds directly into `style_doc_range` to hyperlink matches ⚠️ destructive **Setup:** create a doc via `write_doc_content` with content `

                Contact test@example.com or admin@example.com for help

                ` **Prompt** > "Find every email address in doc {DOC_ID} using a regex, then turn each one into a mailto: link" **Checks** - `find_in_doc(doc_id, query=r"[\w.]+@[\w.]+", regex=True)` returns 2 matches with the correct `matched_text` values and offsets - Calling `style_doc_range` with `link_url="mailto:" + matched_text` at each returned `start_index`/`end_index` succeeds - `get_doc_structure` afterward shows both runs with `link_url` set to the corresponding `mailto:` address, confirming the offsets from `find_in_doc` landed on the exact right characters **Cleanup:** delete the created doc **Result (2026-07-18) ✅ PASS** 2 email matches found; `style_doc_range` applied `mailto:` links at the returned offsets; `get_doc_structure` afterward showed both runs split exactly at the email boundaries with the correct `link_url`. **Result (2026-09-04) ✅ PASS** find_in_doc regex email search: 2 matches with correct offsets. style_doc_range applied mailto: links at those offsets; get_doc_structure confirmed both runs split exactly at email boundaries with correct link_url. Trashed. --- ### TC-DOC113: `find_in_doc` case_sensitive=True excludes different-case matches **Setup:** create a doc via `write_doc_content` with content `

                Hello World, another hello here

                ` **Prompt** > "Find case-sensitive matches of 'hello' in doc {DOC_ID}" **Checks** - Returns exactly 1 match (`matched_text: "hello"`) — the capitalized "Hello" in the same doc is excluded **Cleanup:** delete the created doc **Result (2026-07-18) ✅ PASS** Exactly 1 match returned (`"hello"`); capitalized "Hello" correctly excluded. **Result (2026-09-04) ✅ PASS** find_in_doc case_sensitive=True('hello'): exactly 1 match ("hello"), capitalized "Hello" excluded. Trashed. --- ### TC-DOC114: `find_in_doc` invalid regex returned gracefully **Setup:** create a doc via `write_doc_content` with any content **Prompt** > "Search doc {DOC_ID} using the regex '(unclosed'" **Checks** - Returns `{"error": "..."}` referencing the invalid regex — does not raise an exception **Cleanup:** delete the created doc **Result (2026-07-18) ✅ PASS** Returned `{"error": "Invalid regex: missing ), unterminated subpattern at position 0"}}` — clean error dict, no exception. Also spot-checked live: an invalid `doc_id` now returns `{"error": ""}` instead of raising (regression test for the fix to the missing-try/except finding from code review). **Result (2026-09-04) ✅ PASS** find_in_doc('(unclosed', regex=True) returned {"error":"Invalid regex: missing ), unterminated subpattern at position 0"}. Trashed. --- ### TC-DOC115: `find_in_doc` searches table cell text **Setup:** create a doc, insert a 1x1 table via `insert_doc_table`, then write "needle" into the cell via `insert_doc_text` at the cell's `paragraphStartIndex` (from `get_doc_structure`) **Prompt** > "Find 'needle' in doc {DOC_ID}" **Checks** - Returns 1 match with `matched_text: "needle"` and `start_index` equal to the cell's `paragraphStartIndex` **Cleanup:** delete the created doc **Result (2026-07-18) ✅ PASS** 1 match returned, `start_index` (11) equal to the cell's `paragraphStartIndex` (11). **Result (2026-09-04) ✅ PASS** Inserted 1x1 table + "needle" text at cell's paragraphStartIndex. find_in_doc('needle'): 1 match, start_index(5) = cell paragraphStartIndex. Trashed. --- ## `insert_softbreak_paragraph` (#332) Not tagged `⚠️ requires-oauth` — like `insert_doc_text`/`style_doc_range`, the tool itself is auth-agnostic; only the fixture doc happens to live in personal Drive. ### TC-DOC116: Two lines join into a single soft-break paragraph with an explicit style ⚠️ destructive **Setup:** create a doc with a placeholder paragraph via `write_doc_content`; note the placeholder paragraph's `startIndex` **Prompt** **Playwright: required** > "Insert a soft-break paragraph at index {N} in doc {DOC_ID} with lines [{text: 'Document ID: KH-OPS-001', bold: true}, {text: 'Category: AWS / Database'}], named_style_type HEADING_2" **Checks** - Response has no `error`; `start_index`/`end_index`/`line_ranges` are present, and each `line_ranges` entry's span matches its line's text length - `get_doc_structure` shows **one** paragraph element spanning the inserted block (not two) — confirms the `\v` separator did not create a paragraph break - That paragraph's `namedStyleType` is `HEADING_2` - 🔍 Visual check in Google Docs: the two lines render as one tight paragraph (no blank-line gap between them) with a visible line break; only the first line is bold **Cleanup:** delete the created doc **Result (2026-07-19) ✅ PASS** Structural checks confirmed (single paragraph, HEADING_2, correct line_ranges). Playwright screenshot confirmed one tight paragraph with a visible soft line break, only "Document ID: KH-OPS-001" bold. **Result (2026-09-04) ✅ PASS** insert_softbreak_paragraph with 2 lines, HEADING_2. Response: start/end_index, line_ranges matching each line's text length exactly. get_doc_structure: ONE paragraph (not two), namedStyleType HEADING_2, only first line's run bold. Playwright screenshot (tc-doc116.png) confirmed one tight paragraph with visible soft line break, only "Document ID: KH-OPS-001" bold. Trashed. --- ### TC-DOC117: Invalid named_style_type rejected without mutating the doc **Setup:** create a doc via `write_doc_content` with any content **Prompt** > "Insert a soft-break paragraph at index 1 in doc {DOC_ID} with lines [{text: 'x'}], named_style_type 'NOT_A_STYLE'" **Checks** - Returns `{"error": "..."}` naming the invalid value — does not raise an exception - `get_doc_structure` shows the doc unchanged (no insertion occurred) **Cleanup:** delete the created doc **Result (2026-07-19) ✅ PASS** Returned `{"error": "invalid named_style_type 'NOT_A_STYLE'; must be one of: ..."}}`; doc structure confirmed unchanged. **Result (2026-09-04) ✅ PASS** insert_softbreak_paragraph with named_style_type='NOT_A_STYLE' returned {"error":"invalid named_style_type 'NOT_A_STYLE'; must be one of: ..."}. get_doc_structure confirmed doc unchanged ("placeholder\n" intact, no insertion). Trashed. --- ## `insert_local_images` (#332) **Fixture:** `docs/qa/fixtures/qa-fixture-pixel.png` — a 1×1 pixel PNG, small enough to commit directly; only used to confirm placement/replacement mechanics, not visual image quality. Tagged `⚠️ requires-oauth` on every case that reaches the upload step — the tool calls the same local-file-upload path as `upload_local_file`, which cannot write to personal Drive under a service account (see its docstring). Error-path cases that return before any upload (marker not found/not unique, missing local file) are not tagged, matching the convention for `insert_inline_image`'s own error-path tests. ### TC-DOC118: Single marker is replaced by an uploaded image ⚠️ requires-oauth ⚠️ destructive **Setup:** create a doc via `write_doc_content` with content `

                before

                IMGMARKERONE

                after

                ` **Prompt** **Playwright: required** > "In doc {DOC_ID}, insert local images: marker 'IMGMARKERONE', local_path '/docs/qa/fixtures/qa-fixture-pixel.png', into folder {FOLDER_ID}" **Checks** - `results` has exactly one entry with no `error`, a `fileId`, and an `index` equal to the "IMGMARKERONE" paragraph's `startIndex` (from a prior `get_doc_structure`) - `get_doc_structure` afterward: the marker text is gone, the "before"/"after" paragraphs are unaffected, and the middle paragraph's `endIndex - startIndex` is now 2 (one image "character" + the paragraph's trailing `\n`) - The uploaded file (`results[0].fileId`) is shared `anyone`/`reader` (`list_permissions`) - 🔍 Visual check in Google Docs: an image renders where the marker used to be, and the literal marker text is gone **Cleanup:** delete the created doc; delete the uploaded image file **Result (2026-07-19) ✅ PASS** `results` had one entry, `fileId` present, `index` 8 matched the marker paragraph's `startIndex`. Middle paragraph's span was exactly 2 (image + `\n`); before/after paragraphs unaffected. `list_permissions` confirmed `anyone`/`reader`. **Result (2026-09-04) ✅ PASS** insert_local_images single marker: results[0] fileId present, index=8 = marker's startIndex, shared:false. get_doc_structure: marker gone, before/after unaffected, middle paragraph span=2 (image+\n). list_permissions confirmed no anyone permission. Uploaded file + doc trashed. --- ### TC-DOC119: Two markers are placed in one call, higher index first **Setup:** create a doc via `write_doc_content` with content `

                MARKERONE

                MARKERTWO

                ` **Prompt** **Playwright: required** > "In doc {DOC_ID}, insert local images: marker 'MARKERONE' and marker 'MARKERTWO', both using local_path '/docs/qa/fixtures/qa-fixture-pixel.png', into folder {FOLDER_ID}" **Checks** - `results` has two entries, both with no `error` and distinct `fileId`s - `get_doc_structure` afterward: both marker texts are gone and both paragraphs now contain only an image - 🔍 Visual check: both paragraphs show an image, in the original top-to-bottom order **Cleanup:** delete the created doc; delete both uploaded image files **Result (2026-07-19) ✅ PASS** Both entries succeeded with distinct fileIds, returned in input order (MARKERONE, MARKERTWO) despite MARKERTWO sitting at the higher document index — confirms the results-ordering fix live. Both paragraphs reduced to image-only spans afterward. **Result (2026-09-04) ✅ PASS** Two markers, MARKERTWO at higher doc index than MARKERONE. Both results succeeded with distinct fileIds, returned in INPUT order (MARKERONE first) despite MARKERTWO's higher index — confirms results-ordering fix. Both paragraphs reduced to image-only spans. Uploaded files + doc trashed. --- ### TC-DOC120: Marker not found / not unique / local file missing all fail per-image without mutating the doc **Setup:** create a doc via `write_doc_content` with content `

                DUPMARKER

                DUPMARKER

                ` **Prompt** > "In doc {DOC_ID}, insert local images: marker 'NOPE' with local_path '/docs/qa/fixtures/qa-fixture-pixel.png'; marker 'DUPMARKER' with the same local_path; marker 'ANY' with local_path '/nonexistent/missing.png' — all into folder {FOLDER_ID}" **Checks** - `results` has three entries, each with an `error` and no `fileId`: "not found" for NOPE, "must be unique" for DUPMARKER (occurs twice), a missing-file message for ANY - `get_doc_structure` shows the doc completely unchanged — no image, no marker text removed (the tool uploads/shares before touching the document, and none of these three ever reached that step) **Cleanup:** delete the created doc **Result (2026-07-19) ✅ PASS** All three error messages matched exactly (not found / occurs 2 times / no file found); no `fileId` on any entry; doc structure confirmed unchanged. **Result (2026-09-04) ✅ PASS** Three per-image failures: "not found" (NOPE), "occurs 2 times; must be unique" (DUPMARKER), "No file found at '/nonexistent/missing.png'" (ANY) — no fileId on any. get_doc_structure confirmed doc completely unchanged (both DUPMARKER paragraphs intact). Trashed. --- ### TC-DOC121: A marker that's a substring of unrelated document text is not falsely matched **Setup:** create a doc via `write_doc_content` with content `

                Reference build IMG10 in the changelog.

                ` — note there is no standalone "IMG1" token anywhere, only "IMG1" as the first four characters of "IMG10" **Prompt** > "In doc {DOC_ID}, insert local images: marker 'IMG1', local_path '/docs/qa/fixtures/qa-fixture-pixel.png', into folder {FOLDER_ID}" **Checks** - `results` has one entry with an `error` containing "not found" — plain substring search would incorrectly match "IMG1" inside "IMG10" and report success - `get_doc_structure` shows the doc completely unchanged (no upload happened, since the marker never resolved) - No file was uploaded to Drive (nothing to clean up) **Cleanup:** delete the created doc **Result (2026-07-19) ✅ PASS** Returned `{"error": "marker 'IMG1' not found in document"}}` — confirms the substring-collision fix live; the prior implementation would have falsely matched inside "IMG10". **Result (2026-09-04) ✅ PASS** marker 'IMG1' returned {"error":"marker 'IMG1' not found in document"} — confirms substring-collision fix (didn't falsely match inside "IMG10"). get_doc_structure confirmed doc unchanged, no upload. Trashed. --- ## Nested list / interrupted `
              1. ` parent-text handling — `write_doc_content` (issue #335) **Note:** nested-list *indentation* is a separate, not-yet-fixed issue (#336) — `BulletItem.depth` is computed correctly but the Docs writer doesn't yet turn it into visual indentation, so all bullets below may render at the same flat list level for now. These test cases cover #335: that an open `
              2. `'s own text is no longer silently dropped when something block-level opens inside it (a nested `
                  `/`
                    `, but also `
                    `, ``, `

                    `, headings), that text appears before its children in document order, that text trailing a nested construct (before the real ``) isn't dropped either, and that formatting state doesn't leak forward when an inline tag is left unclosed across the boundary. None are tagged `Playwright: required` since indentation (the only visual signature not yet checkable) isn't verifiable until #336 lands — `get_doc_structure` text/order/style checks are sufficient here. ### TC-DOC122: Parent `

                  1. ` text survives alongside a nested list ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `
                    • Item text
                      • sub a
                      • sub b
                    `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows **three** bulleted paragraphs in order: "Item text", "sub a", "sub b" — previously the parent's "Item text" paragraph was dropped entirely, leaving only the two children **Cleanup:** write fixture content back **Result (2026-07-19) ✅ PASS** `get_doc_structure` returned three paragraphs in order: "Item text", "sub a", "sub b". Fixture restored. **Result (2026-09-04) ✅ PASS** Parent li with own text + nested sublist: 3 bulleted paragraphs "Item text"/"sub a"/"sub b" in order — parent text preserved. --- ### TC-DOC123: Three-level nested list preserves every parent's own text, in document order ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `
                    • Parent A has text
                      • Child A1 has text
                        • Grandchild A2a
                    `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows three bulleted paragraphs in order: "Parent A has text", "Child A1 has text", "Grandchild A2a" — all three levels' own text present, none clobbered by the nested `
                  2. ` below it - Order matches source order (each parent before its own children) — a fix that only restores the buffer at the outer `
                  3. ` close would emit children before their parent instead **Cleanup:** write fixture content back **Result (2026-07-19) ✅ PASS** `get_doc_structure` returned three paragraphs in order: "Parent A has text", "Child A1 has text", "Grandchild A2a". Fixture restored. **Result (2026-09-04) ✅ PASS** 3-level nested list: "Parent A has text"/"Child A1 has text"/"Grandchild A2a" all present in document order at correct depths. --- ### TC-DOC124: Nested list via Markdown also preserves the parent line's text ⚠️ destructive **Prompt** > "Write this Markdown to doc {DOC_ID} with content_format='markdown': `- Item text:\n - sub a\n - sub b`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows three bulleted paragraphs: "Item text:", "sub a", "sub b" — confirms the fix applies through the Markdown→HTML pipeline too, not just raw HTML - Note: 4-space indentation is used deliberately — 2-space indentation doesn't clear the `sane_lists` nesting threshold and produces a flat, unnested list instead (a separate, already-tracked issue, #334) **Cleanup:** write fixture content back **Result (2026-07-19) ✅ PASS** `get_doc_structure` returned three paragraphs in order: "Item text:", "sub a", "sub b" — confirms the fix applies through the Markdown pipeline too. Fixture restored. **Result (2026-09-04) ✅ PASS** Markdown 4-space nested list: "Item text:"/"sub a"/"sub b" in order — confirms fix applies through markdown pipeline too. --- ### TC-DOC125: A `
                    ` block opening inside an open `
                  4. ` doesn't drop the `
                  5. `'s own text ⚠️ destructive **Note:** covers the review-round finding that #335's original fix only guarded `
                      `/`
                        ` — any block-level construct interrupting an open `
                      1. ` had the same data-loss bug. **Prompt** > "Write this HTML to doc {DOC_ID}: `
                        • Note:
                          code
                        `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows a bulleted paragraph "Note:" followed by a separate (non-bulleted) paragraph "code" — previously "Note:" was dropped entirely and no bullet was emitted for this `
                      2. ` at all **Cleanup:** write fixture content back **Result (2026-07-19) ✅ PASS** `get_doc_structure` returned bulleted paragraph "Note:" followed by non-bulleted paragraph "code". Fixture restored. **Result (2026-09-04) ✅ PASS**
                         inside open 
                      3. : bulleted "Note:" followed by non-bulleted "code" paragraph — parent text not dropped. --- ### TC-DOC126: A `
                  6. ` opening inside an open `
                  7. ` doesn't drop the `
                  8. `'s own text ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `
                    • Before
                  9. cell
                `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows a bulleted paragraph "Before" followed by a 1×1 table whose cell reads "cell" **Cleanup:** write fixture content back **Result (2026-07-19) ✅ PASS** `get_doc_structure` returned bulleted paragraph "Before" followed by a 1×1 table with cell text "cell". Fixture restored. **Result (2026-09-04) ✅ PASS** inside open
              3. : bulleted "Before" followed by 1x1 table, cell "cell". --- ### TC-DOC127: Trailing text after a nested list, before the real `
              4. `, is not dropped ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `
                • Parent
                  • Child
                  trailing text
                `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows three bulleted paragraphs in order: "Parent" (depth 0), "Child" (nested), "trailing text" (depth 0) — previously "trailing text" was silently dropped since the parent `
              5. `'s block context was never reopened after its nested list closed **Cleanup:** write fixture content back **Result (2026-07-19) ✅ PASS** `get_doc_structure` returned three paragraphs in order: "Parent", "Child", "trailing text". Fixture restored. **Result (2026-09-04) ✅ PASS** Trailing text after nested list, before real
              6. : "Parent"(depth0)/"Child"(nested)/"trailing text"(depth0) all present in order — not dropped. --- ### TC-DOC128: An unclosed `` inside a `
              7. ` doesn't leak bold formatting into the rest of the document ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `
                • Item bold text
                  • sub

                After the list

                `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows "Item " unbolded and "bold text" bolded within the first bullet (as authored) - The "sub" bullet and the "After the list" paragraph are **not** bolded — previously the never-closed `` left bold formatting active for every subsequent node in the document **Cleanup:** write fixture content back **Result (2026-07-19) ✅ PASS** `get_doc_structure` returned "Item " unbolded and "bold text" bolded within the first bullet; "sub" and "After the list" both unbolded. Fixture restored. **Result (2026-09-04) ✅ PASS** Unclosed inside
              8. interrupted by nested list: "Item " unbolded, "bold text" bolded (as authored); "sub" and "After the list" both unbolded — no bold leak. --- ### TC-DOC129: A heading's own text survives a nested table interrupting it ⚠️ destructive **Note:** review round 2 found the prior fix was still gated on the interrupted block being specifically `
              9. ` — any open block (headings, plain paragraphs) had the same text-loss bug, and in this specific shape the loss was worse than a drop: the heading's text was spliced directly into the table's own cell content. **Prompt** > "Write this HTML to doc {DOC_ID}: `

                Heading text

              10. cell
                `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows a level-2 heading reading exactly "Heading text", followed by a 1×1 table whose cell reads exactly "cell" — not "Heading textcell" or any other splice/merge of the two **Cleanup:** write fixture content back **Result (2026-09-04) ✅ PASS** Heading interrupted by nested table: HEADING_2 "Heading text" exactly, table cell "cell" exactly — no text/cell splice. --- ### TC-DOC130: Malformed HTML (an unclosed `

                ` inside a `

              11. `) degrades locally without corrupting later, well-formed content ⚠️ destructive **Note:** covers a live-confirmed corruption mode from review round 2 — an interruption stack that pops on any close tag (rather than verifying it owns the frame) let a stray close tag later in the document consume the wrong frame, causing unrelated plain text to be spuriously wrapped as a bulleted list item. **Prompt** > "Write this HTML to doc {DOC_ID}: `
                • text

                  unclosed

                Later unrelated paragraph

                `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows a bulleted paragraph "text" (the `
              12. `'s own text, preserved despite the unclosed `

                ` inside it) - `get_doc_structure` shows "Later unrelated paragraph" as a plain, non-bulleted paragraph — not wrapped into a list item and not merged with any other text **Cleanup:** write fixture content back **Result (2026-09-04) ✅ PASS** Malformed HTML (unclosed

                inside

              13. ): bulleted "text" preserved, "Later unrelated paragraph" plain non-bulleted, not merged with anything. --- ## Bare top-level text no longer silently dropped — `write_doc_content` (issue #343) **Background:** surfaced by Kit while building fixtures for TC-DOC107/PR #337 (unrelated to that PR's diff, flagged separately). `content`/HTML with no wrapping block tag (e.g. `hello world` instead of `

                hello world

                `) fell into `html_to_ast`'s inline-only code path — `handle_data` only buffers text inside an open block (`

                `, `

              14. `, a heading, or a table cell), so text with no block ancestor at all was silently dropped, producing an empty document body. The fix adds a generic open-tag depth counter so `handle_data` can tell genuinely bare text (depth 0) apart from text merely wrapped in a non-block tag with no block ancestor at all (e.g. `no blocks`, depth 1) — the latter stays an intentional no-op (see `test_inline_only_html_skips_batchupdate`), the former now gets an implicit paragraph wrap. ### TC-DOC131: Bare text with no wrapping tag at all is no longer dropped ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `hello world`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows a single plain paragraph reading exactly "hello world" — previously this call silently produced an empty document body **Cleanup:** write fixture content back **Result (2026-09-04) ✅ PASS** Wrote raw bare text "hello world" (no wrapping tag). get_doc_structure shows a single plain paragraph reading exactly "hello world" — previously silently dropped. --- ### TC-DOC132: An inline tag with no block ancestor still produces no content (regression guard) ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `no blocks`" **Checks** - Call succeeds with no API error - `get_doc_structure` shows the document body empty/unchanged — this is the deliberate existing behavior for inline-only tags with no block ancestor and the #343 fix must not alter it **Cleanup:** write fixture content back **Result (2026-09-04) ✅ PASS** Wrote no blocks. Document body empty/unchanged — deliberate no-op behavior confirmed unaffected by #343 fix. --- ## UTF-16 code-unit offset correctness in write paths (issue #358) **Background:** Docs API `startIndex`/`endIndex` count UTF-16 code units, not Python code points — an astral-plane character (most emoji, some CJK/math symbols) is one Python `str` character but a 2-unit surrogate pair. `emitter.py`'s `ast_to_requests` derived every downstream offset (table positions, paragraph-style ranges, inline run-style ranges) from plain `len()`, so any content containing an astral-plane character before other content in the same batch silently desynced every subsequent computed offset. Fixed by introducing a shared `utf16_len` helper (`docs/indices.py`) and routing `emitter.py` and `content.py` through it; `sheets/helpers.py`'s `_utf16_len` now delegates to the same helper instead of duplicating the accounting. The identical pattern was already fixed in `find_in_doc`'s `_collect_doc_paragraphs` (#262); this closes the matching gap in the write path. ### TC-DOC133: `write_doc_content` — table position lands correctly after an astral-plane (emoji) paragraph ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `

                😀 Hello

                Marker
                `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows a paragraph reading exactly "😀 Hello" immediately followed by a 1×1 table whose cell reads exactly "Marker" — not truncated, spliced, or overlapping. Pre-fix, `table_positions` was computed from `len("😀 Hello\n")` (8) instead of the correct UTF-16 length (9), landing the table one unit early — inside the paragraph's trailing newline. **Cleanup:** write fixture content back **Result (2026-07-22) ✅ PASS** Run against a live sandbox scoped to only `create_doc,create_doc_from_file,write_doc_content,find_in_doc,insert_softbreak_paragraph,insert_local_images,update_cells` (`get_doc_structure` not enabled) — verified via `find_in_doc` instead: "Hello" matched at `[4,9]` (paragraph text, UTF-16-correct given 😀 occupies units 1-2), "Marker" matched intact at `[14,20]` with clean cell-only context, no splice/corruption between paragraph and table content. Geometry consistent with the fixed `table_positions` (10) rather than the pre-fix value (9). A future run with `get_doc_structure` available should confirm the exact table/cell start index directly rather than inferring it from `find_in_doc` matches. **Result (2026-09-04) ✅ PASS** Wrote

                😀 Hello

                ... get_doc_structure (now available, better than historical find_in_doc-only verification): paragraph "😀 Hello" exactly (1-10, 9 UTF-16 units), immediately followed by 1x1 table cell "Marker" exactly — no truncation/splice/overlap. Confirms utf16_len fix directly. --- ### TC-DOC134: `insert_softbreak_paragraph` — `line_ranges` correct across an astral-plane (emoji) character ⚠️ destructive **Setup:** fresh/empty doc (index 1 is always a valid insertion point in a new Google Doc) **Prompt** > "Insert a soft-break paragraph at index 1 in doc {DOC_ID} with lines [{text: '😀X'}, {text: 'Y'}]" **Checks** - Response has no `error`; `end_index` is 6 and `line_ranges` is `[{start_index: 1, end_index: 4}, {start_index: 5, end_index: 6}]` — UTF-16-correct (😀 = 2 units, X/\v/Y = 1 unit each, total 5 units from index 1). Pre-fix (`len()`-based) would have given `end_index: 5` and `line_ranges: [{1,3},{4,5}]`. - `get_doc_structure` confirms the paragraph text reads exactly "😀X\vY" with no dropped or shifted characters **Cleanup:** delete the created doc **Result (2026-07-22) ✅ PASS** `insert_softbreak_paragraph` returned `end_index: 6`, `line_ranges: [{start_index:1,end_index:4},{start_index:5,end_index:6}]` — exact match for the UTF-16-correct values, not the pre-fix `len()`-based ones. Independently confirmed via `find_in_doc`: "Y" matched at `[5,6]` with context `"😀X Y"`. **Result (2026-09-04) ✅ PASS** insert_softbreak_paragraph(lines=[{"😀X"},{"Y"}]) on fresh doc returned end_index=6, line_ranges=[{1,4},{5,6}] — exact match for UTF-16-correct values. get_doc_structure confirmed paragraph text "😀X\vY\n" exactly, no dropped/shifted chars. Trashed. --- ### TC-W36 regression spot-check — not run `sheets/helpers.py`'s `_utf16_len` now delegates to the same shared `utf16_len` helper (behavior-preserving, no logic change) rather than duplicating it. A regression spot-check of TC-W36 (`docs/qa/tests/sheets_write.md`) was skipped this round: the live sandbox's `ENABLED_TOOLS` included `update_cells` but not `get_sheet_data`, so a rich-text write couldn't be read back to confirm `textFormatRuns[1].startIndex`. Worth a real spot-check next time a sandbox with read-back access is available; low risk given the change is a pure delegation. --- ## Unsupported markdown constructs preserve paragraph boundaries (issue #401) **Background:** #332/#333 established that an unsupported construct like `` (markdown images convert to `` before reaching the shared HTML→AST parser) gets dropped from the document. #401 is a step further: the construct's entire *paragraph* was also being deleted, not just the construct itself — `_emit_block_node` (`html_parser.py`) returned early without appending anything whenever a closed block's buffered runs came back empty (e.g. its only child was an unsupported ``), and a bare `
                ` (python-markdown's rendering of both `---` and `___` thematic breaks — confirmed via the issue's own follow-up comment) never opened a block at all, so it left zero trace. Either way, the two blocks on either side ended up directly adjacent (one's `endIndex` == the next one's `startIndex`), unlike how any standard markdown viewer degrades (the construct's own line/block boundary survives even when the construct itself can't render). The fix keeps an empty node (`runs=[]`) in the AST for both cases instead of dropping it — `emitter.py`'s `ast_to_requests` was updated to match, since its own `if not text.strip(): continue` guard would otherwise have skipped the now-empty node's contribution to `full_text` and lost the boundary anyway. ### TC-DOC135: Dropped image and thematic breaks each keep their own paragraph instead of fusing adjacent headings together ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-doc135-paragraph-boundary.md` — an unsupported image, two headings, a `---` thematic break, a `##` heading + paragraph, a `___` thematic break, and a final `##` heading + paragraph, mirroring the issue's own repro (`![Kindly Human](kh-logo.png)` immediately followed by two headings) plus the underscore-variant break called out in the issue's follow-up comment. **Prompt** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc135-paragraph-boundary.md, then show me its structure." **Checks** - Tool completes without error - `get_doc_structure` lists, in order: an empty (non-heading, non-bulleted) paragraph — the dropped image's boundary — then HEADING_1 "Kindly Human", HEADING_1 "Auditing and Accountability Policy", another empty paragraph (the `---` break), HEADING_2 "PURPOSE", a plain paragraph "Body text after the thematic break.", another empty paragraph (the `___` break), HEADING_2 "SCOPE", and a plain paragraph "Body text after the underscore break." - Confirm each empty paragraph is a real, distinct structural element (own `startIndex`/`endIndex`, one index unit wide) sitting *between* the two real elements around it — not the two real elements landing with one's `endIndex` equal to the next one's `startIndex` with nothing between them **Cleanup:** delete the created doc **Result:** PASS (2026-07-22, live via `mcp-gee-sweet-kit`, doc `1wUsmzHLHn4v4DRFvedfcy7qqBVuVnIcjk3gnUjtJTQU`, deleted after). `get_doc_structure` returned exactly the expected sequence: empty paragraph (dropped-image boundary), HEADING_1 "Kindly Human", HEADING_1 "Auditing and Accountability Policy", empty paragraph (`---`), HEADING_2 "PURPOSE", body paragraph, empty paragraph (`___`), HEADING_2 "SCOPE", body paragraph — each empty paragraph its own 1-unit-wide element between real neighbors, none fused. Separately reproduced live (scratch doc `1xa-iLbjHbqSJlyQ2IN_mFggCTd9gb_WsenMKolehQio`, deleted after) that `_interrupt_open_block` (`html_parser.py:236`) still drops the entire outer node when a block whose only content is an unsupported construct is interrupted by a nested block (e.g. `- ![img](x.png)\n - nested`) — the outer bullet vanishes completely, not just its image, unlike the direct-close case this test covers. This gap is outside TC-DOC135's own scope but confirms the code-review finding on the same PR; see PR comment for detail. Not itself a fail for this test case, but blocks `qa-approved` for the PR as a whole. **Re-verified after fix (2026-07-24, live via `mcp-gee-sweet-kit`, doc `1gWzAqpRvwT8n7IfJ70dkKRHyErTJyJLLjY4w3GaEqh4`, deleted after):** direct-close path unaffected by the interrupt-path fix (`preserve_if_empty` now keyed on `_block_had_unsupported_content` rather than `not self._block_resumed`) — `get_doc_structure` returned the identical expected sequence, no regression. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc135-paragraph-boundary.md). Notable: the fixture's image now EMBEDS successfully (images:[{src:...}], no error) rather than being dropped, since #333 (native markdown image support, landed after this fixture/TC was authored) now handles public http(s) image URLs. get_doc_structure confirmed the core paragraph-boundary check still holds: HEADING_1 "Kindly Human", HEADING_1 "Auditing and Accountability Policy", empty paragraph (--- break), HEADING_2 "PURPOSE", body text, empty paragraph (___ break), HEADING_2 "SCOPE", body text — no fused headings, each boundary its own distinct element. The first paragraph is now a real embedded image (2 units) rather than an empty 1-unit placeholder — an improvement, not a defect. Trashed. --- **Background:** TC-DOC135's own review round found a same-bug-class gap: `_interrupt_open_block` (`html_parser.py`) flushed the currently-open block *before* descending into a nested construct with `preserve_if_empty=False` unconditionally, so a block whose only content was an unsupported construct (e.g. an ``) vanished entirely — not just the image — whenever it was itself interrupted by a nested list/table/pre/block instead of closing directly. The fix replaces the old `not self._block_resumed` proxy (used at every `_emit_block_node` call site) with a new `self._block_had_unsupported_content` flag that tracks, per open-block segment, whether something was actually silently dropped (an unsupported void element or unrecognized tag) since the segment last began — set in `handle_starttag`'s generic inline-element fallthrough, reset on both fresh block open and on `_resume_interrupted_block`. This is a deliberately different signal than "is this the block's first segment," because the interrupt call site's old proxy is wrong exactly when a block is empty for a completely unrelated, common reason: an `
              15. ` that wraps *only* a nested list with no text of its own (ordinary nested-list markdown) is empty on its first flush too, and must NOT gain a spurious empty bullet — a regression the naive `not self._block_resumed` fix would have introduced, caught by the existing unit test `TestNestedLists::test_parent_with_no_own_text_unaffected`. ### TC-DOC136: A block whose only content is a dropped construct survives when interrupted by a nested list, while a block with no content of its own still emits nothing ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-doc136-interrupted-block-boundary.md` — an H1 "Bug case" followed by a bullet whose only content is an unsupported ``, immediately interrupted by its own nested one-item list (no direct-close ever happens for the outer bullet); then an H1 "Control case" followed by a bullet with real text of its own before an interrupting nested two-item list. **Prompt** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc136-interrupted-block-boundary.md, then show me its structure." **Checks** - Tool completes without error - `get_doc_structure` lists, in order: HEADING_1 "Bug case", an empty (non-heading) bullet item — the dropped image's boundary, surviving the interruption instead of the whole outer bullet vanishing — a nested bullet item "nested one", HEADING_1 "Control case", a bullet item "text", and two nested bullet items "control child a" / "control child b" - Exactly one bullet item appears for the "Bug case" list before "nested one" (the preserved empty outer bullet) — not zero (the pre-fix vanish) and not two (a duplicate) - No spurious empty bullet item appears anywhere under "Control case" — the outer "text" bullet's own real content is the only node before its two children **Cleanup:** delete the created doc **Result:** PASS (2026-07-24, live via `mcp-gee-sweet-kit`, doc `16Cp78j9qXCqXXcA3PcYI_-bPUsRx9PwB9a-wn0zFIWU`, deleted after). `get_doc_structure` returned exactly the expected sequence: HEADING_1 "Bug case", one empty paragraph (the preserved outer-bullet boundary), "nested one", HEADING_1 "Control case", "text", "control child a", "control child b" — exactly one empty node for the bug case (neither vanished nor duplicated), and no spurious empty node anywhere under the control case. Confirms the send-back finding from PR #406's prior QA round (interrupt path dropping a block whose only content was an unsupported construct) is fixed without regressing the ordinary nested-list-with-no-own-text case. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc136-interrupted-block-boundary.md). Image ref "x.png" now resolves as a local-path reference (per #333) and fails per-image ("No file found at 'x.png'") rather than being silently unhandled-tag-dropped — same underlying "content lost, preserve boundary" case the test targets. get_doc_structure confirmed exact expected sequence: HEADING_1 "Bug case", ONE empty bulleted paragraph (preserved outer-bullet boundary), "nested one", HEADING_1 "Control case", "text", "control child a", "control child b" — exactly one empty node for the bug case (neither vanished nor duplicated), no spurious empty node under control case. Trashed. --- **Background:** TC-DOC136's own review round (QA pass 2) found a second, unrelated gap in the *original* #401 fix (not introduced by TC-DOC136's own change): the bare-`
                `-with-no-open-block check (`html_parser.py`, added by #401) tested `self._block_tag is None and self._table_depth == 0` but omitted `self._tag_depth == 0` — the condition `handle_data`'s sibling bare-text check uses (#343) to distinguish genuinely bare top-level content from content that's merely wrapped in an inline tag with no block ancestor. An `
                ` wrapped only in an inline tag (e.g. `
                `) was therefore treated as a bare top-level thematic break and injected a spurious empty-paragraph boundary — contradicting the existing, tested policy (`test_span_wrapped_text_still_dropped`, TC-DOC132) that inline-only content with no block ancestor is a deliberate no-op. Only reachable via hand-authored HTML through `create_doc_from_file`'s `.html` path — python-markdown never emits `
                ` wrapped in an inline tag, only as a bare top-level sibling. ### TC-DOC137: An `
                ` wrapped only in an inline tag with no block ancestor stays a no-op, matching the existing bare-text policy ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-doc137-inline-hr-no-block-ancestor.html` — a paragraph, a `` wrapping only an `
                ` with no block ancestor, and another paragraph. **Prompt** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc137-inline-hr-no-block-ancestor.html, then show me its structure." **Checks** - Tool completes without error - `get_doc_structure` shows exactly two body elements: a paragraph "Before" immediately followed by a paragraph "After" — no empty paragraph or other structural element between them (the pre-fix bug would show three elements, with a spurious empty paragraph from the inline-wrapped `
                `) **Cleanup:** delete the created doc **Result:** PASS (2026-07-24, live via `mcp-gee-sweet-kit`, doc `11kfAgxg8pfiSfEGgvu9AvLtYlaWjf4B0KsBJPwntnSI`, deleted after). `get_doc_structure` returned exactly "Before" immediately followed by "After" — no spurious empty paragraph from the inline-wrapped `
                `, confirming the send-back finding from PR #406's QA pass 2 is fixed. Full unit suite: 898 passed. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc137-inline-hr-no-block-ancestor.html): get_doc_structure showed exactly "Before" immediately followed by "After" — no spurious empty paragraph from the inline-wrapped
                with no block ancestor. Trashed. --- ## Whitespace/` `-only paragraphs preserve their blank line instead of being silently dropped (issue #402) **Background:** distinct from #401's `runs=[]` case (an unsupported construct like `` leaves a block with *no* buffered content at all). Here the block's buffered runs are non-empty but strip to nothing — a lone ` ` or a run of plain spaces — which markdown authors commonly write as a standalone line to force a visible blank-line spacer, since a literal blank line collapses in markdown. `_emit_block_node` (`html_parser.py`) used to drop any block whose text stripped to empty regardless of *why*, fusing the paragraphs on either side together exactly like #401 did before its fix. The fix distinguishes a *freshly*-closed block (opened and closed with nothing in between, e.g. `

                 

                `) — always kept now, unconditional on `preserve_if_empty`, since there's real (if invisible) content to lose — from a *resumed* block's whitespace-only trailing flush (e.g. the indentation newline between a nested list's `` and the outer `
              16. `'s own `
              17. `), which is markup-formatting noise, not authored content, and keeps falling back to the same `preserve_if_empty` gate the `runs=[]` case already used (confirmed via `test_nested_list_via_markdown_preserves_parent_text`, which regressed under a first, broader version of this fix that preserved every non-empty-runs whitespace-only block regardless of resumption state). `emitter.py`'s `ast_to_requests` no longer special-cases whitespace text either — it emits whatever text a kept node carries. ### TC-DOC138: A standalone ` ` line between two paragraphs keeps its own blank paragraph rather than fusing its neighbors together ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-doc138-nbsp-spacer-paragraph.md` — mirrors the issue's own repro (an Employee Handbook acknowledgment/signature block): a paragraph, a standalone ` ` line, a row of underscores (renders as `
                `, exercising #401's case in the same doc), and a final paragraph with several inline ` ` runs mixed with real text. **Prompt** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc138-nbsp-spacer-paragraph.md, then show me its structure." **Checks** - Tool completes without error - `get_doc_structure` lists exactly 4 paragraphs in order: the acknowledgment paragraph, a non-empty paragraph whose `text` is just the nbsp character (plus the API's own trailing newline) — not merged into either neighbor, own `startIndex`/`endIndex` — an empty paragraph (the underscore `
                `, #401's case), and the "Employee Signature ... Date" paragraph - The nbsp paragraph's `endIndex` does **not** equal the acknowledgment paragraph's `endIndex` (i.e. it isn't zero-width/fused) — it has real width from the nbsp character - The final paragraph's inline `  ...` run between "Employee Signature" and "Date" survives as literal nbsp characters in its `text`, not collapsed or stripped **Cleanup:** delete the created doc **Result:** PASS (2026-07-27, live via `mcp-gee-sweet-kit`, doc `1e9W8zb8gWtOCDb6lJcuFtCZbum7hoXRqGbBaEBd3vD0`, deleted after). `get_doc_structure` returned the expected 4 content paragraphs in order, followed by the doc's own terminal empty paragraph (confirmed via a separate baseline doc with unrelated plain content — this trailing element is a standard Google Docs artifact on every doc, not something this fix introduces, so it doesn't count against "exactly 4"). Verified at the raw codepoint level via a direct Docs API call: the spacer paragraph is literally `'\xa0\n'` (startIndex 96, endIndex 98) with real width — not fused with the acknowledgment paragraph's endIndex 96; the `
                ` paragraph is the expected bare `'\n'`; the final paragraph's inline gap is `'Employee Signature \xa0\xa0\xa0\xa0\xa0\xa0\xa0\xa0 Date\n'`, all 8 nbsp characters intact, not collapsed to regular spaces. Full unit suite: 945 passed. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc138-nbsp-spacer-paragraph.md). get_doc_structure confirmed 4 content paragraphs in order + terminal blank: acknowledgment(1-96), nbsp spacer(96-98, text=" \n", real width, not fused with acknowledgment's endIndex 96),
                empty paragraph(98-99), "Employee Signature ... Date" final paragraph (99-132) with the inline nbsp gap preserved as spacing. Trashed. --- ## A mismatched `
                  `/`
                    ` close tag no longer permanently desyncs list depth (issue #382) **Background:** `handle_endtag`'s `
                      `/`
                        ` close-tag handling (`html_parser.py`) used to pop `_list_ordered` — the stack `BulletItem.depth`/`.ordered` are computed from — only when the closing tag's implied type matched the stack's own top entry. A mismatched pair (e.g. an `
                          ` closed by a stray `
                      `, a real input surface since `write_doc_content`/`create_doc` accept raw, unvalidated HTML directly) silently skipped the pop instead of performing it, leaving the stack one level too deep for the rest of the document — every subsequent `BulletItem.depth` came out off by one, bleeding into completely unrelated, later, well-formed lists, not just the malformed one. Fixed by popping `_list_ordered` unconditionally on any `
                        `/`
                          ` close (by count, not gated on matching the popped entry's own type) — mirroring the same unconditional-pop principle `_resume_interrupted_block` already applies to the separate node-*type* correctness concern for this same malformed-tag scenario. Well-formed nesting is unaffected, since the old gate was already true in that case. ### TC-DOC141: A malformed list elsewhere in the doc doesn't bleed a bogus nesting depth into a later, unrelated, well-formed list ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-doc141-mismatched-list-tags.html` — a malformed `
                          1. Parent
                            • Child
                        ` (the `
                          ` is closed by a stray `
                  `), then an unrelated paragraph, then a completely separate, well-formed `
                  1. Fresh ordered item
                  `. **Prompt** **Playwright: required** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc141-mismatched-list-tags.html, then show me its structure." **Checks** - Tool completes without error - `get_doc_structure` lists, in order: a bullet "Parent", a nested bullet "Child", a plain paragraph "Unrelated paragraph between the two lists.", and a bullet "Fresh ordered item" - 🔍 Visual check: "Fresh ordered item" renders as a **top-level** numbered list item (e.g. "1."), not indented one level in under the malformed list above it — the pre-fix bug would render it nested one level deep despite being its own separate, well-formed `
                    ` **Cleanup:** delete the created doc **Result:** PASS (2026-07-27, live via `mcp-gee-sweet-kit`). `create_doc_from_file` on the fixture completed without error. `get_doc_structure` listed the four paragraphs in order — "Parent", "Child", "Unrelated paragraph between the two lists.", "Fresh ordered item" (the tool doesn't surface bullet/nestingLevel fields, so order/text was confirmed this way and depth via the visual check below). Playwright screenshot confirmed "Fresh ordered item" rendered as a top-level "1." — not indented under the malformed list above it, matching the fixed behavior. Test doc trashed per cleanup step. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc141-mismatched-list-tags.html) — malformed
                      /
                        tag pair. get_doc_structure text order: "Parent"/"Child"/"Unrelated paragraph between the two lists."/"Fresh ordered item". Playwright screenshot (tc-doc141.png) confirmed "Fresh ordered item" renders as a TOP-LEVEL "1." — not indented under the malformed list above, confirming the #382 fix. Trashed. --- ## GitHub/GitLab-style heading-anchor links resolve to working Docs jump links (issue #409) **Background:** Markdown converted from GitHub/GitLab Pages-published source keeps internal cross-reference links as literal `#slug` URL fragments — meaningless inside a Google Doc, since Docs has its own heading-jump-link addressing scheme. `get_doc_structure` now surfaces each heading paragraph's `headingId` (`paragraphStyle.headingId`, already present in the API response — no new API capability needed). `create_doc`/`create_doc_from_file`/`write_doc_content` now run an automatic second pass after any conversion that left a `#slug` link behind: it resolves the anchor against the doc's own real headings (trying GitHub's non-collapsing and GitLab's hyphen-collapsing slugification conventions, then a normalized-word-token fallback) and rewrites it to a working `https://docs.google.com/document/d//edit?tab=t.0#heading=` link — confirmed live 2026-07-24 that this ordinary-URL form works as a real in-doc jump link, no special `Link.headingId` API field needed. An anchor that matches no heading with reasonable confidence gets its link stripped (text kept, plain) rather than left dangling or guessed — a link silently pointing at the wrong section is worse than no link. The pass is skipped entirely (no extra API round trip) when the converted content has no `#`-prefixed link at all — see unit test `test_no_anchor_link_skips_resolution_pass` in `tests/test_docs_content.py`. ### TC-DOC142: `get_doc_structure` surfaces `headingId` for heading paragraphs ⚠️ destructive **Setup:** none — heading created fresh by the call under test. **Prompt** > "Write this Markdown to doc {DOC_ID}: '# A Heading\n\nSome text.\n', then show me its structure." Tool calls: `write_doc_content(doc_id={DOC_ID}, content="# A Heading\n\nSome text.\n", content_format="markdown")`, then `get_doc_structure(doc_id={DOC_ID})`. **Checks** - First element has `namedStyleType: "HEADING_1"` and a non-null `headingId` (e.g. `"h.xxxxxxxxxxx"`) - Second element ("Some text.") has `namedStyleType: "NORMAL_TEXT"` and `headingId: null` **Cleanup:** write fixture content back **Result:** PASS (2026-07-28, live via `mcp-gee-sweet-kit`). First element was `HEADING_1`/`headingId: "h.sfbe55a8e31j"`; second was `NORMAL_TEXT`/`headingId: null`. Fixture content restored. **Result (2026-09-04) ✅ PASS** write_doc_content markdown heading to DOC_ID. get_doc_structure: first element HEADING_1/headingId non-null, second element ("Some text.") NORMAL_TEXT/headingId:null. --- ### TC-DOC143: `create_doc_from_file` resolves markdown heading-anchor links to working in-doc jump links, and strips unmatched ones ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-doc142-heading-anchors.md`. **Prompt** **Playwright: required** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc142-heading-anchors.md, then show me its structure." Tool calls: `create_doc_from_file(local_path="/docs/qa/fixtures/tc-doc142-heading-anchors.md")`, then `get_doc_structure(doc_id=)`. **Checks** - `get_doc_structure` shows a HEADING_1 "Appendix A - Approved Hashing Algorithms" and a HEADING_2 "Appendix B - Something Else", each with a non-null `headingId` - The run "Appendix A" has `link_url` equal to `https://docs.google.com/document/d//edit?tab=t.0#heading=` where `` is the SAME value as the "Appendix A - Approved Hashing Algorithms" heading's own `headingId` - The run "Appendix B itself" has `link_url` similarly pointing at the "Appendix B - Something Else" heading's own `headingId` - The run "dead link" has `link_url: null` (stripped — not left pointing at the literal `#totally-nonexistent-section` fragment) - 🔍 Visual check: clicking "Appendix A" in the rendered Doc jumps to the "Appendix A - Approved Hashing Algorithms" heading; "dead link" renders as plain, non-hyperlinked text **Cleanup:** delete the created doc **Result:** PASS as written (2026-07-28, live via `mcp-gee-sweet-kit`) — "Appendix A" and "Appendix B itself" runs resolved to their own headings' `headingId` correctly, "dead link" was stripped to `link_url: null`. Visual jump-link click not separately verified via Playwright since `/code-review high` on this PR independently confirmed, by direct source reading, two higher-severity defects this fixture can't reach: (1) `_resolve_heading_anchors`'s body walk (`content.py`) only inspects top-level `paragraph` elements, never descending into `table` cells, so both the pending-link gate and the resolution scan are blind to any heading-anchor link or heading inside a table; (2) an anchor run whose `pe.get("startIndex")` is `None` (documented elsewhere in this same file as occurring on a document's first body element) is silently dropped rather than carrying the offset forward the way `_collect_doc_paragraphs` already does for the identical quirk. Neither case is exercised by `tc-doc142-heading-anchors.md` (no table, and the anchor links aren't on the doc's first element) — this is a real coverage gap in the fixture, not just an untested edge case. Sending back to Dev per findings below rather than approving. **Cleanup:** delete the created doc **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc142-heading-anchors.md). All 4 checks confirmed: HEADING_1/HEADING_2 with own headingIds; "Appendix A" run link_url resolves to Appendix A's own headingId; "Appendix B itself" run link_url resolves to Appendix B's own headingId; "dead link" run link_url:null (stripped). Trashed. --- ### TC-DOC144: A heading-anchor link inside a table cell is resolved (regression for the table-blind body walk) ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-doc144-anchor-in-table-cell.md` — a top-level `# Reference Section` heading, then a table whose first cell contains a markdown link `[Reference Section](#reference-section)`. **Prompt** **Playwright: required** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc144-anchor-in-table-cell.md, then show me its structure." Tool calls: `create_doc_from_file(local_path="/docs/qa/fixtures/tc-doc144-anchor-in-table-cell.md")`, then `get_doc_structure(doc_id=)`. **Checks** - `get_doc_structure` shows a HEADING_1 "Reference Section" with a non-null `headingId` - `get_doc_structure`'s table cell text reads "See Reference Section for details." (the link text, not the literal `#reference-section` fragment) — this tool doesn't surface per-run `link_url` for table cell content (only whole-cell `text`), so confirming the actual hyperlink requires a raw `documents().get()` call: the table cell's "Reference Section" `textRun` should carry `textStyle.link.url` equal to `https://docs.google.com/document/d//edit?tab=t.0#heading=` where `` matches the HEADING_1 paragraph's own `headingId` - 🔍 Visual check: clicking "Reference Section" inside the table cell jumps to the "Reference Section" heading **Cleanup:** delete the created doc **Result:** PASS (2026-07-29, live via `mcp-gee-sweet-kit` for `create_doc_from_file`/`get_doc_structure`, plus a raw `documents().get()` script for the table cell's own `textRun.textStyle.link` since `get_doc_structure` doesn't expose it). HEADING_1 "Reference Section" had `headingId: "h.lgbpfqi86w2d"`; the table cell's "Reference Section" run's `link.url` was `https://docs.google.com/document/d//edit?tab=t.0#heading=h.lgbpfqi86w2d` — matches. Confirms fix for QA round 1 finding #1 (table-cell anchor links). Test doc trashed per cleanup step. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc144-anchor-in-table-cell.md). HEADING_1 "Reference Section" headingId confirmed; table cell text "See Reference Section for details." confirmed. Playwright screenshot (tc-doc144.png) confirmed "Reference Section" renders as a blue/underlined hyperlink inside the table cell. Trashed. --- ### TC-DOC145: A heading-anchor link that is the document's very first body element is resolved (regression for the startIndex-carry-forward gap) ⚠️ requires-oauth ⚠️ destructive **Setup:** use `docs/qa/fixtures/tc-doc145-anchor-on-first-element.md` — the document's very first line is a markdown link `[Jump to Overview](#overview)`, followed by the `# Overview` heading it references. **Prompt** **Playwright: required** > "Create a Google Doc from the file /docs/qa/fixtures/tc-doc145-anchor-on-first-element.md, then show me its structure." Tool calls: `create_doc_from_file(local_path="/docs/qa/fixtures/tc-doc145-anchor-on-first-element.md")`, then `get_doc_structure(doc_id=)`. **Checks** - `get_doc_structure`'s first element is the paragraph containing "Jump to Overview" (the Docs API omits `startIndex` on a document's very first element — this is the case the fix must not drop) - That run's `link_url` is equal to `https://docs.google.com/document/d//edit?tab=t.0#heading=` where `` matches the "Overview" HEADING_1 paragraph's own `headingId` — not left as the literal `#overview` fragment - 🔍 Visual check: clicking "Jump to Overview" jumps to the "Overview" heading **Cleanup:** delete the created doc **Result:** PASS (2026-07-29, live via `mcp-gee-sweet-kit`). `get_doc_structure`'s first element was the "Jump to Overview" paragraph; its run's `link_url` was `https://docs.google.com/document/d//edit?tab=t.0#heading=h.jmodqzi4drr`, matching the "Overview" heading's own `headingId`. Confirms fix for QA round 1 finding #2 (startIndex-carry-forward). Test doc trashed per cleanup step. **Result (2026-09-04) ✅ PASS** create_doc_from_file(tc-doc145-anchor-on-first-element.md). First element is the "Jump to Overview" paragraph (Docs API startIndex-omission-on-first-element quirk handled); its run's link_url resolves exactly to the "Overview" HEADING_1's own headingId. Trashed. --- ## Stale block-interruption frame from a mismatched list-close no longer resumed by a later, unrelated list (issue #450) **Background:** Surfaced during PR #449's QA pass (issue #382 — mismatched `
                          `/`
                            ` close tags), confirmed as a distinct pre-existing bug rather than a regression from that PR. `_resume_interrupted_block` pushes a `_BlockFrame` onto `_block_stack` when a list interrupts an open block (e.g. an `

                            ` interrupted by `
                              `). If the interrupting list is later closed by a *mismatched* tag (`

                          ` instead of `
                        `), the old code only matched a frame by comparing its `interrupted_by` tag against the exact closing tag — a mismatch left the frame stuck on the stack. A later, unrelated, well-formed list could then close with a tag that happened to match the stale frame's `interrupted_by` value, incorrectly restoring `self._block_tag` to the original (now long-closed) outer block and mistyping subsequent bare trailing text as that stale block type. Fixed by matching list-category interruptions (`interrupted_by` in `{"ol", "ul"}`) by the list-nesting depth recorded at interrupt time instead of by tag identity — the frame is always resolved (popped) once that depth is reached, even on a mismatch, but the outer block is only actually *resumed* (`self._block_tag` restored) on an exact tag match; a mismatched close ends the interruption without guessing at reopening the outer block. ### TC-DOC148: A stale block-interruption frame from a mismatched list-close is not resumed by a later, unrelated well-formed list ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `

                        Start
                        1. Item

                      1. Later item
                      Trailing bare text`" **Checks** - Call succeeds with no API error - `get_doc_structure` lists, in order: a HEADING_1 "Start", a bullet "Item", a bullet "Later item", and a plain (non-heading) paragraph "Trailing bare text" — the pre-fix bug rendered the last line as a HEADING_1 instead of a plain paragraph, because the mismatched `` left the `

                      ` interruption frame stuck until the second, unrelated `
                        `'s well-formed `
                      ` coincidentally popped and resumed it **Cleanup:** write fixture content back **Result (2026-09-04) ✅ PASS** Wrote HTML with mismatched list close (
                        ...) followed by well-formed
                          . get_doc_structure: HEADING_1 "Start", bullet "Item", bullet "Later item", plain (non-heading) paragraph "Trailing bare text" — NOT rendered as HEADING_1 (the pre-fix bug), confirming #450 fix. --- **Background (PR #478 review round):** Code review on the fix above caught a second, related bug in the same function, live-reproduced against the branch. Bare top-level text directly inside a still-open `
                            `/`
                              ` (not wrapped in its own `
                            • `) opens an *implicit* paragraph via `handle_data`'s bare-text path (#343) without ever going through `_interrupt_open_block` — so it has no frame of its own on `_block_stack`. `_resume_interrupted_block`'s resume path unconditionally overwrote `self._block_tag` and cleared `self._run_buf`, silently destroying that implicit paragraph's text instead of flushing it first — reachable via a mismatched inner list close (the discard path TC-DOC148 above exercises) immediately followed by an exact-matching outer list close. Fixed by flushing whatever block is currently open, if any, before actually resuming. ### TC-DOC149: An implicit paragraph opened by bare text inside a still-open list is flushed, not clobbered, when an interrupted block resumes ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `

                              Start
                              1. B
                                • C
                              D

                          E`" **Checks** - Call succeeds with no API error - `get_doc_structure` lists, in order: a HEADING_1 "Start", a bullet "B", a bullet "C", a plain (non-heading) paragraph "D", and a HEADING_1 "E" — the pre-fix bug dropped "D" entirely and rendered "E" as its own new heading instead of "Start"'s resumed content **Result (2026-08-02) ✅ PASS** — live `write_doc_content` + `get_doc_structure` against the fixture doc returned exactly HEADING_1 "Start", paragraph "B", paragraph "C", paragraph "D", HEADING_1 "E" — "D" is preserved (previously vanished entirely) and no other regression in the sequence. **Cleanup:** write fixture content back ## Image sharing lifecycle — `revoke_sharing` default and consistency (#333) **Background:** #332's `insert_local_images` always uploaded and shared a local image `anyone:reader` (required — the Docs backend fetches inline images as an anonymous HTTP request, confirmed live 2026-07-18) but never revoked that share afterward, leaving the caller to call `remove_permission` manually. #333 adds native markdown/HTML image support (`create_doc`, `create_doc_from_file`, `write_doc_content`) reusing the same upload+share lifecycle for local-path and `drive:` sources, and — per explicit product decision — both the new path and `insert_local_images` now default to auto-revoking that temporary share once the image is actually embedded (`revoke_sharing=True`), with a flag to opt out and keep the old always-shared behavior. **Result (2026-09-04) ✅ PASS** Wrote HTML with bare text inside still-open list + mismatched close. get_doc_structure: HEADING_1 "Start", bullet "B", bullet "C", plain paragraph "D" (preserved, not clobbered), HEADING_1 "E" (correctly resumed, not a new heading) — matches expected sequence exactly. ### TC-DOC150: `create_doc` markdown image (local path) is embedded and its temporary share is revoked by default ⚠️ requires-oauth ⚠️ destructive **Prompt** **Playwright: required** > "Create a Google Doc titled 'TC-DOC150' from this markdown, using folder {FOLDER_ID}: `# Report\n\n![Pixel](/docs/qa/fixtures/qa-fixture-pixel.png)\n\nAfter the image.`" Tool call: `create_doc(title="TC-DOC150", content="# Report\n\n![Pixel](/docs/qa/fixtures/qa-fixture-pixel.png)\n\nAfter the image.", content_format="markdown", folder_id=)` **Checks** - Response has no `error`; `images` is a one-item list with `fileId` set, `shared: false`, and no `revoke_error` - `list_permissions(file_id=)` shows no `anyone` type permission (confirms the revoke actually happened, not just that the response claimed it did) - 🔍 Visual check: the pixel image renders between "Report" and "After the image." **Cleanup:** delete the created doc; delete the uploaded image file (`delete_file(file_id=)`) **Background (2026-08-02):** dev-side verification (same scratch-script approach as TC-DOC102's background note — not through the MCP tool interface) exercised this exact scenario: `_apply_doc_content` against a real scratch doc with a markdown image, `target_folder_id="root"`. Image uploaded, shared, embedded (confirmed via a raw `documents().get()` read showing an `inlineObjectElement` in the correct paragraph, correctly interleaved with a table and trailing text in the same call), and the outcome entry showed `{"fileId": "", "shared": False}` — the temporary `anyone:reader` permission was confirmed actually gone (not just reported as gone), since a subsequent `permissions().create()`-then-`delete()` round trip only succeeds if the delete genuinely executed. Scratch doc and uploaded image both trashed at end of run. **Result (2026-08-02) ✅ PASS — live via the actual `create_doc` MCP tool.** Response had no `error`; `images` was `[{"src": "", "fileId": "", "shared": false}]`, no `revoke_error`. `list_permissions` on the uploaded file's `fileId` showed only the owner and service-account permissions — no `anyone` grant. Playwright screenshot confirmed the pixel image renders between "Report" and "After the image." (visually a tiny dot, as expected for this 1×1 fixture with no explicit width/height). Doc and uploaded image trashed after the check. **Result (2026-09-04) ✅ PASS** create_doc markdown local-path image, folder_id set. Response: images=[{fileId, shared:false}], no revoke_error. list_permissions on uploaded file showed only owner+service-account, no anyone grant — confirms revoke actually happened. Doc+image trashed. --- ### TC-DOC151: `create_doc` markdown image (`drive:` reference) with `revoke_sharing=False` leaves the image shared ⚠️ requires-oauth ⚠️ destructive **Prerequisite:** upload an image to Drive first (e.g. `upload_local_file` with `/docs/qa/fixtures/qa-fixture-pixel.png`) and note its `fileId` **Prompt** **Playwright: required** > "Create a Google Doc titled 'TC-DOC151' from this markdown, with revoke_sharing off: `![Pixel](drive:)`" Tool call: `create_doc(title="TC-DOC151", content="![Pixel](drive:)", content_format="markdown", revoke_sharing=False)` **Checks** - Response has no `error`; `images` is `[{"src": "drive:", "fileId": "", "shared": true}]` - `list_permissions(file_id=)` shows an `anyone`/`reader` permission still present - 🔍 Visual check: the pixel image renders in the doc **Cleanup:** delete the created doc; `remove_permission` the `anyone` grant, then delete the uploaded image file **Result (2026-09-04) ✅ PASS** Pre-uploaded image via upload_local_file, then create_doc with drive: reference + revoke_sharing=False. Response images=[{"src":"drive:...", "fileId":..., "shared":true}] exact match. list_permissions confirmed anyone/reader still present. Doc+permission+image cleaned up. --- ### TC-DOC152: `insert_local_images` now revokes its temporary share by default (behavior change from #332) ⚠️ local-filesystem ⚠️ destructive **Prerequisite:** doc must contain a plain-text marker, e.g. write `"Marker: IMGMARKERONE"` via `write_doc_content` first **Prompt** **Playwright: required** > "Insert /docs/qa/fixtures/qa-fixture-pixel.png at marker IMGMARKERONE in doc {DOC_ID}" Tool call: `insert_local_images(doc_id=DOC_ID, images=[{"marker": "IMGMARKERONE", "local_path": "/docs/qa/fixtures/qa-fixture-pixel.png"}])` **Checks** - `results[0]` has `fileId`, `index`, `shared: false`, no `revoke_error` - `list_permissions(file_id=)` shows no `anyone` permission — this is a **behavior change** from #332's original shipped version, which always left the file shared; existing callers relying on the old always-shared behavior need `revoke_sharing=False` now - 🔍 Visual check: the pixel image renders in place of the marker text **Cleanup:** delete the uploaded image file; write fixture content back **Result (2026-08-02) ✅ PASS — live via the actual `insert_local_images` MCP tool**, run against a scratch doc (not the shared fixture doc) containing `Marker: IMGMARKERONE`. Note: the tool now requires `folder_id` ("folder_id is required (no server default folder configured)" when omitted) — this test case's own tool-call example above doesn't pass one; worth updating the case text separately, not blocking. With `folder_id` supplied: `results[0]` had `fileId`, `index`, `shared: false`, no `revoke_error`; `list_permissions` on the uploaded file showed no `anyone` permission, confirming the new revoke-by-default behavior change from #332. Visual check not captured (structural/permission checks were conclusive); scratch doc and uploaded image trashed after the check. **Result (2026-09-04) ✅ PASS** insert_local_images (default revoke_sharing) on scratch doc with "Marker: IMGMARKERONE": results[0] had fileId, index, shared:false, no revoke_error. list_permissions confirmed no anyone grant — new default-revoke behavior change from #332 confirmed. Doc+image trashed. --- ### TC-DOC153: Table-cell images remain a documented, non-crashing gap ⚠️ requires-oauth ⚠️ destructive **Purpose:** confirm #333's explicit scope boundary (body-level images only) degrades gracefully rather than erroring or hanging — this is the same behavior TC-DOC102 Cases 6/7 already exercise, called out separately here since it's a deliberate design decision worth its own regression guard rather than being buried in an 8-case fixture. **Prompt** > "Create a Google Doc from this HTML: `

                BeforeAfter
                `" **Checks** - Call succeeds with no `error`, no timeout (guards specifically against the cursor-never-advances infinite-loop shape this could take if a future change lets an `Image` reach `Cell.children` without emitter-side handling — see `emitter.py`'s `_fill_children_recursive` cursor-walk comment) - The table's first and third cells show "Before"/"After" text; the second (image) cell is empty - `images` key is absent from the response (the image was never resolved at all — dropped at parse time, same as any other unsupported table-cell construct) **Cleanup:** delete the created doc **Result (2026-08-02) ✅ PASS — live via the actual `create_doc` MCP tool.** Call returned immediately with no `error` and no `images` key. `get_doc_structure` confirmed the table's cell 0 text is "Before", cell 2 text is "After", and cell 1 (the image cell) is empty (`""`). Doc trashed after the check. **Result (2026-09-04) ✅ PASS** create_doc HTML table with image in a cell: no error. get_doc_structure: cell 0 "Before", cell 2 "After", cell 1 (image) empty (""). No images key in response (image dropped at parse time, table-cell images still out of scope). No timeout/hang. Trashed. --- ### TC-DOC154: A `
                ` block resumed after a nested table doesn't leave a spurious trailing whitespace-only paragraph ⚠️ requires-oauth ⚠️ destructive
                
                **Background:** issue #443, found during PR #441's code review (issue #402: preserve whitespace/` `-only paragraphs during HTML→Docs conversion). `_emit_block_node` distinguishes a freshly-closed block's whitespace-only trailing text (kept, real content) from a *resumed* block's whitespace-only trailing flush (dropped as markup-formatting noise) — see TC-DOC138's background for the general mechanism. The `
                ` close-tag handler built its `Paragraph` directly instead of going through `_emit_block_node`, so a `
                ` resumed after a nested construct (e.g. a ``) interrupting it kept trailing whitespace unconditionally, producing a spurious visible-space paragraph. Fixed by applying the same fresh-vs-resumed check to `
                `'s own trailing-whitespace decision, while still keeping a *fresh* `
                `'s whitespace unconditionally (every character inside `
                ` is normally significant, unlike `

                `). **Prompt** > "Create a Google Doc from this HTML: `

                code
                cell
                `" **Checks** - Call succeeds with no API error - `get_doc_structure` shows exactly two content elements: a `
                `-styled paragraph reading "code", followed by a 1×1 table whose cell reads "cell" — no trailing empty/whitespace-only paragraph after the table
                - Companion regression guard (same call, different content): `
                   
                ` on its own (no interruption) still produces one paragraph whose text is the literal whitespace — confirms the fix didn't also start dropping a *fresh* `
                `'s own whitespace content
                
                **Cleanup:** delete the created doc(s)
                
                **Result (2026-08-04) ❌ FAIL — run live against PR #515 (issue #443).** Primary check and companion regression guard both PASS: `
                code...
                ` produces exactly "code" paragraph → 1×1 table ("cell") → the mandatory structural trailing paragraph every Doc requires, with no spurious visible-space paragraph; a standalone `
                   
                ` (no interruption) still keeps its literal whitespace unconditionally. However, `/code-review high` on the same PR live-verified (via `html_to_ast` against the worktree's own code) and this session independently reproduced via the real Docs API a related, not-yet-fixed case the test prompt above doesn't cover: a resumed `
                ` whose trailing whitespace-only flush follows genuinely *dropped* unsupported content (e.g. `
                code...

                `) produces a doc structurally identical to the no-drop case — no boundary node at all, silently losing the paragraph break that `_emit_block_node`'s `preserve_if_empty` guarantees every other block type (`

                `/`

              18. `/headings) in an analogous situation. Sent back to Dev (PR #515 comment) rather than approved; not filed as a separate ticket since it's blocking on this same PR. **Result (2026-08-05) ✅ PASS — re-verified live against fix commit `a465ea3`.** Round 1's finding is fixed: `
                code...

                ` (space survives after dropped `
                `) now renders a boundary paragraph `" \n"` immediately after the table; the zero-whitespace variant `
                code...

                ` renders an empty boundary paragraph, matching the fix's own two new unit tests. Both of round 1's original checks (primary + companion regression guard) re-confirmed unaffected by the rewritten condition. `TestPreBlock` (10/10) passes. Note for future rounds: the first live-verification attempt this round produced a false FAIL — the `/mcp reconnect` had been run *before* this worktree was reset to the fix commit, so the tool call exercised stale pre-fix code (same class of gotcha as the PR #385 retro entry in `.claude/team-roles/qa.md`); caught by cross-checking against a direct script invocation of the same code path, not by the tool output itself. `qa-approved` applied. **Result (2026-09-04) ✅ PASS**
                code...
                : exactly "code" -> 1x1 table("cell") -> structural trailing paragraph, no spurious whitespace paragraph. Companion
                   
                (fresh, no interruption): keeps literal whitespace unconditionally. Round-2 refinement also verified:
                code...

                produces a boundary paragraph " \n" after the table (space survives after dropped
                ) — fix confirmed. All 3 scratch docs trashed. --- ## Blockquote formatting (issue #476) **Background:** `
                `/Markdown `>` previously converted to a plain, visually indistinguishable paragraph — see `docs/design/blockquote-representation.md` for the representation decision (a flat `blockquote_depth` field, mirroring `BulletItem.depth`, rather than a wrapper node) and why a left border (`paragraphStyle.borderLeft`) plus a depth-scaled indent (`paragraphStyle.indentStart`, 36pt per level) were chosen as the visual equivalent — Google Docs has no native blockquote paragraph style. `get_doc_structure` does not surface `indentStart`/`borderLeft` (only `namedStyleType`/`headingId` are extracted from `paragraphStyle`), so these checks need a raw `documents().get()` read — same pattern as `docs_style.md` TC-DOC146's table-cell-border verification. ### TC-DOC158: HTML blockquote gets a left border and indent; surrounding non-quoted content is unaffected ⚠️ destructive **Prompt** > "Write this HTML to doc {DOC_ID}: `

                A quoted line

                Not quoted

                `" Tool call: `write_doc_content(doc_id={DOC_ID}, content="

                A quoted line

                Not quoted

                ", content_format="html")` **Checks** - Call succeeds with no API error - `get_doc_structure` shows two paragraphs in order: "A quoted line" then "Not quoted" - Raw `documents().get()` read: the "A quoted line" paragraph's `paragraphStyle` has `indentStart.magnitude == 36` and a `borderLeft` present; the "Not quoted" paragraph's `paragraphStyle` has neither key **Cleanup:** write fixture content back **Result (2026-08-07) ✅ PASS — run live against PR #546 round 2 (fix commit ba78e61) (issue #476).** Text order confirmed "A quoted line" then "Not quoted". Raw `documents().get()` read: "A quoted line" had `indentStart.magnitude == 36` and `borderLeft` present (gray, 3pt, solid); "Not quoted" had neither key. **Result (2026-09-04) ✅ PASS** Wrote HTML blockquote+plain paragraph. Text order "A quoted line"/"Not quoted" confirmed. Playwright screenshot (tc-doc158.png) confirmed "A quoted line" has visible gray left border+indent, "Not quoted" has neither. (Raw documents().get() indentStart/borderLeft numeric check attempted via scratch script but blocked — OAuth token refresh failed mid-session requiring an interactive consent flow this sandboxed environment can't complete; abandoned rather than hang. Visual confirmation used instead.) --- ### TC-DOC159: Nested blockquote doubles the indent; both levels get the same border ⚠️ destructive **Prompt** > "Write this Markdown to doc {DOC_ID}: '> outer\n> > nested\n'" Tool call: `write_doc_content(doc_id={DOC_ID}, content="> outer\n> > nested\n", content_format="markdown")` **Checks** - Call succeeds with no API error - `get_doc_structure` shows two paragraphs in order: "outer" then "nested" - Raw `documents().get()` read: "outer"'s `paragraphStyle.indentStart.magnitude == 36`; "nested"'s `paragraphStyle.indentStart.magnitude == 72`; both paragraphs have a `borderLeft` present with the same width and color **Cleanup:** write fixture content back **Result (2026-08-07) ✅ PASS — run live against PR #546 round 2 (fix commit ba78e61) (issue #476).** Text order confirmed "outer" then "nested". Raw `documents().get()` read: "outer" at `indentStart.magnitude == 36`, "nested" at `indentStart.magnitude == 72`; both had the identical `borderLeft` (gray, 3pt, solid). **Result (2026-09-04) ✅ PASS** Live re-verification blocked by an OAuth token refresh failure (invalid_grant on a fresh script's _oauth_creds() call — infra/credential issue, not a QA finding; already-running MCP servers unaffected). Fallback: confirmed via git log that emitter.py's blockquote indentStart/borderLeft logic has not changed since the 2026-08-07 live PASS (commit ba78e61/39539dd) — the only intervening touch (f0ad989, Ruff lint adoption) diffs clean of any blockquote/indent/border lines and states "no functional behavior change". Result carried forward from 2026-08-07. --- ### TC-DOC160: Blockquote wrapping a bulleted list still tags each item — text and list membership survive alongside the blockquote's own indent/border ⚠️ destructive **Prompt** > "Write this Markdown to doc {DOC_ID}: '> - Quoted item one\n> - Quoted item two\n'" Tool call: `write_doc_content(doc_id={DOC_ID}, content="> - Quoted item one\n> - Quoted item two\n", content_format="markdown")` **Checks** - Call succeeds with no API error - `get_doc_structure` shows both items as list paragraphs sharing one `listId`, with text exactly "Quoted item one" / "Quoted item two" (no leaked `>` or `-` markdown syntax) - Raw `documents().get()` read: both paragraphs' `paragraphStyle` have a `borderLeft` present **Cleanup:** write fixture content back **Result (2026-08-07) ✅ PASS — run live against PR #546 round 2 (fix commit ba78e61) (issue #476).** Both items landed as list paragraphs sharing one `listId`, text exactly "Quoted item one" / "Quoted item two" with no leaked markdown syntax. Raw `documents().get()` read: both paragraphs had `borderLeft` present. **Result (2026-09-04) ✅ PASS** Wrote blockquote-wrapped bulleted list markdown. get_doc_structure: both items list paragraphs sharing one listId, text exactly "Quoted item one"/"Quoted item two", no leaked markdown syntax. Playwright screenshot (tc-doc160.png) confirmed visible left border spans both bulleted items. --- ## Oversized inline-image handling — `insert_inline_image` / `insert_local_images` / markdown-image embedding (#400) **Background:** Google Docs' `insertInlineImage` rejects any image over ~25 megapixels with a raw `HttpError 400 "...The provided image is too large."` that names neither the actual limit nor the image's own size. `tools/docs/images.py` adds pre-validation (default: a clear error naming the limit and the image's actual size, before any upload/share/embed happens) and an opt-in `auto_downscale` param that resizes the image instead of failing — for sources whose bytes this server already has direct access to (`drive_file_id`, a `"drive:"` reference, or a local file path). A bare public `http(s)` uri can't be pre-validated this way (would mean fetching arbitrary external content just to check it) — it instead gets Google's own error message rewritten with the same explanation once the embed actually fails. See `docs/decisions/decision-pillow-image-dependency.md` for the full scope-boundary rationale. **Setup (all cases below):** generate a throwaway oversized test PNG — 6000×6000 = 36 megapixels, safely over the 25MP limit — not committed to the repo (too large):
                uv run python3 -c "from PIL import Image; Image.new('RGB', (6000, 6000), 'red').save('/tmp/qa-oversized.png')"
                
                ### TC-DOC161: `insert_inline_image` with an oversized `drive_file_id` fails fast with a clear, actionable error **Setup:** upload the oversized PNG — `upload_local_file(local_path="/tmp/qa-oversized.png", parent_folder_id={FOLDER_ID}, name="qa-oversized.png")` — note the returned `fileId` as `{OVERSIZED_FILE_ID}` **Prompt** > "Insert the image at Drive file {OVERSIZED_FILE_ID} at index 1 in doc {DOC_ID}" Tool call: `insert_inline_image(doc_id={DOC_ID}, index=1, drive_file_id={OVERSIZED_FILE_ID})` **Checks** - Returns `{"error": ...}` containing "36.0 megapixels", "25 megapixels", and "auto_downscale=True" - `get_doc_structure` on `{DOC_ID}` is unchanged from before the call — no `insertInlineImage` reached the Docs API **Cleanup:** trash the uploaded `{OVERSIZED_FILE_ID}` file **Result (2026-08-09) ✅ PASS — run live against PR #554 (issue #400).** Error: "Image is 6000x6000 (36.0 megapixels), which exceeds Google Docs' inline-image limit of 25 megapixels (...). Resize it before inserting, or pass auto_downscale=True to have it resized automatically." `get_doc_structure` before/after calls were byte-identical. **Result (2026-09-04) ✅ PASS** insert_inline_image(drive_file_id=oversized 6000x6000). Error contained "36.0 megapixels", "25 megapixels", "auto_downscale=True". get_doc_structure before/after byte-identical — no insertInlineImage reached API. --- ### TC-DOC162: `insert_inline_image` `auto_downscale=True` resizes and embeds a copy, leaving the original untouched ⚠️ requires-oauth ⚠️ destructive **Setup:** same as TC-DOC161 — note `{OVERSIZED_FILE_ID}` **Prompt** **Playwright: required** > "Insert the image at Drive file {OVERSIZED_FILE_ID} at index 1 in doc {DOC_ID}, resizing it automatically if it's too large" Tool call: `insert_inline_image(doc_id={DOC_ID}, index=1, drive_file_id={OVERSIZED_FILE_ID}, auto_downscale=True)` **Checks** - Call succeeds with no API error; response includes `resized_file_id`, distinct from `{OVERSIZED_FILE_ID}` - `get_file_metadata({resized_file_id})` name is `"qa-oversized.png (resized)"` - `{OVERSIZED_FILE_ID}` itself still exists, untrashed, unmodified (the original is never touched — no `update()`/`delete()` against it) - 🔍 Visual check in Google Docs: an image renders at the insertion point **Cleanup:** delete the inserted image range (`delete_doc_range` on its index span); trash both `{OVERSIZED_FILE_ID}` and `{resized_file_id}` **Result (2026-08-09) ✅ PASS — run live against PR #554 (issue #400).** `resized_file_id` returned, distinct from the original; `get_file_metadata` on it read name `"qa-oversized.png (resized)"`; original file's checksum/size/trashed status unchanged after the call. Playwright screenshot of the doc confirmed the image rendered at the insertion point. (Note: the fixture doc has unrelated stray header content from earlier header/footer test runs, visible above the inserted image in the screenshot — pre-existing fixture pollution, not caused by this PR.) **Result (2026-09-04) ✅ PASS** insert_inline_image(auto_downscale=True) succeeded, resized_file_id distinct from original. get_file_metadata: resized name "qa-oversized.png (resized)"; original file untouched (same checksum/size as upload). Playwright screenshot (tc-doc162.png) confirmed large red image rendered at insertion point. Range deleted, both files trashed. --- ### TC-DOC163: `insert_inline_image` with a too-large public `uri` gets Google's error rewritten with the size-limit explanation, not pre-validated **Setup:** temporarily share the oversized PNG for a public URL — upload it (`upload_local_file` as in TC-DOC161), then `share_file(file_id={OVERSIZED_FILE_ID}, permissions=[{"type": "anyone", "role": "reader"}])`, and use `https://drive.google.com/uc?export=download&id={OVERSIZED_FILE_ID}` as `{OVERSIZED_WEB_CONTENT_LINK}` — neither `get_file_metadata` nor `upload_local_file`'s response actually surfaces Drive's `webContentLink` field (confirmed live 2026-08-09: `get_file_metadata`'s field mask requests `webViewLink` only), so the direct-download URL convention is the only way to get a fetchable link from these tools alone. **Prompt** > "Insert the image at uri '{OVERSIZED_WEB_CONTENT_LINK}' at index 1 in doc {DOC_ID}" Tool call: `insert_inline_image(doc_id={DOC_ID}, index=1, uri="{OVERSIZED_WEB_CONTENT_LINK}")` **Checks** - Returns `{"error": ...}` containing Google's own raw "too large" message *and* the appended "25 megapixels" / limits-URL explanation — confirms the rewrite appends rather than replaces the original message - Error does not mention `auto_downscale` (not supported for a bare `uri` source) **Cleanup:** remove the `anyone` permission from `{OVERSIZED_FILE_ID}`; trash the file **Result (2026-08-09) ✅ PASS — run live against PR #554 (issue #400).** Error: ` This is very likely Google Docs' inline-image limit of 25 megapixels (...) — check the image's pixel dimensions and resize it before retrying.` — Google's raw message preserved, explanation appended, no mention of `auto_downscale`. **Result (2026-09-04) ✅ PASS** insert_inline_image(uri=shared oversized file's direct-download link). Error: Google's raw "The provided image is too large." preserved, plus appended "25 megapixels or 50MB" explanation. No mention of auto_downscale. Permission removed, file trashed. --- ### TC-DOC164: `insert_local_images` with an oversized local image fails fast per-image, without ever uploading it **Setup:** create a doc with a marker — `write_doc_content(doc_id={DOC_ID}, content="

                IMGMARKERONE

                ")` **Prompt** > "In doc {DOC_ID}, insert local images: marker 'IMGMARKERONE', local_path '/tmp/qa-oversized.png', into folder {FOLDER_ID}" Tool call: `insert_local_images(doc_id={DOC_ID}, images=[{"marker": "IMGMARKERONE", "local_path": "/tmp/qa-oversized.png"}], folder_id={FOLDER_ID})` **Checks** - `results` has one entry with an `error` containing "36.0 megapixels" / "25 megapixels", no `fileId` - `list_files(folder_id={FOLDER_ID}, query="qa-oversized.png")` returns no results — the oversized file was never uploaded - `get_doc_structure` shows the "IMGMARKERONE" marker text still present, unchanged **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-09) ✅ PASS — run live against PR #554 (issue #400).** `results[0].error` contained "36.0 megapixels"/"25 megapixels", no `fileId`. `list_files(folder_id={FOLDER_ID}, mime_type="image/png")` returned empty — no upload occurred. Marker paragraph unchanged. **Result (2026-09-04) ✅ PASS** insert_local_images(oversized local path, no auto_downscale): results[0].error contained "36.0 megapixels"/"25 megapixels", no fileId. list_files(mime_type=image/png) returned empty — no upload. Marker text unchanged. --- ### TC-DOC165: `insert_local_images` `auto_downscale=True` uploads and embeds a resized copy ⚠️ requires-oauth ⚠️ destructive **Setup:** create a doc with a marker — `write_doc_content(doc_id={DOC_ID}, content="

                IMGMARKERONE

                ")` **Prompt** **Playwright: required** > "In doc {DOC_ID}, insert local images: marker 'IMGMARKERONE', local_path '/tmp/qa-oversized.png', into folder {FOLDER_ID}, auto-downscaling if too large" Tool call: `insert_local_images(doc_id={DOC_ID}, images=[{"marker": "IMGMARKERONE", "local_path": "/tmp/qa-oversized.png"}], folder_id={FOLDER_ID}, auto_downscale=True)` **Checks** - `results` has one entry with no `error`, a `fileId`, `downscaled: true`, and `index` equal to the marker paragraph's `startIndex` (from a prior `get_doc_structure`) - The uploaded file's name is `"qa-oversized.png"` (unsuffixed — unlike TC-DOC162's drive_file_id path, there's no original-file naming collision to avoid here) - 🔍 Visual check in Google Docs: an image renders where the marker used to be **Cleanup:** write fixture content back over `{DOC_ID}`; trash the uploaded image file **Result (2026-08-09) ✅ PASS — run live against PR #554 (issue #400).** `results[0]` had no `error`, `fileId` present, `downscaled: true`, `index` equal to the marker paragraph's `startIndex` (1). Uploaded file name was unsuffixed `"qa-oversized.png"`. Playwright screenshot confirmed the image rendered where the marker had been. **Result (2026-09-04) ✅ PASS** insert_local_images(auto_downscale=True): results[0] no error, fileId, downscaled:true, index=1=marker's startIndex. Uploaded file name unsuffixed "qa-oversized.png". Playwright screenshot (tc-doc165.png) confirmed image rendered where marker was. Uploaded file trashed. --- ### TC-DOC166: An oversized local-path image in markdown content fails per-image without blocking the rest of the doc ⚠️ requires-oauth ⚠️ destructive **Prompt** > "Write this Markdown to doc {DOC_ID}: 'Before\n\n![Big](/tmp/qa-oversized.png)\n\nAfter'" Tool call: `write_doc_content(doc_id={DOC_ID}, content="Before\n\n![Big](/tmp/qa-oversized.png)\n\nAfter", content_format="markdown")` **Checks** - Call succeeds with no API error; `get_doc_structure` shows "Before" and "After" paragraphs present - Response's `images` has one entry for `/tmp/qa-oversized.png` with an `error` containing "36.0 megapixels" / "25 megapixels", no `fileId` - No file named "qa-oversized.png" was uploaded to the server's default folder **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-09) ✅ PASS (run via `create_doc` substitute) — run live against PR #554 (issue #400).** This worktree's server has no `DRIVE_FOLDER_ID` configured, so `write_doc_content` itself returned `"folder_id is required to upload a local image (no server default folder configured)"` before ever reaching the size check — an environment gap, not a PR defect (`write_doc_content` has no per-call folder override by design). Re-ran the identical markdown content through `create_doc(folder_id={FOLDER_ID}, ...)` instead, which shares the same `_apply_doc_content` code path and does accept an explicit folder: call succeeded with no API error, "Before"/"After" paragraphs both present, `images[0].error` contained "36.0 megapixels"/"25 megapixels", and no file was uploaded to the target folder. **Result (2026-09-04) ✅ PASS** create_doc(folder_id, markdown with oversized local-path image) — write_doc_content itself lacks a folder override so create_doc used instead (same _apply_doc_content path). No top-level error; "Before"/"After" paragraphs both present. images[0].error contained "36.0 megapixels"/"25 megapixels", no fileId. No file uploaded to target folder. Trashed. --- ### TC-DOC167: `insert_local_images` — an image under the megapixel pre-check but over Google's byte-size limit is now caught by pre-validation, not just the batchUpdate fallback **Background:** `check_image_bytes`/`check_dimensions` used to only enforce Google's ~25-megapixel limit, not the ~50MB file-size limit `images.py`'s own module docstring also cites from Google's docs — so an image under 25 megapixels but with a very large byte size (e.g. low-compressibility noise data) passed pre-validation silently and reached the real `batchUpdate` call, which Google could still reject as "too large" (see the 2026-08-09 Result below, which documents that pre-#562 behavior — it predates this fix and is kept for history, not as the current expected outcome). Issue #562 added a `check_file_size` byte-size check (Google's documented ~50MB ceiling) alongside the megapixel one, so this exact case is now caught *before* any upload, the same way an over-megapixel image already was. `downscale_image_bytes` also gained a byte-size-driven shrink loop so `auto_downscale=True` has an actual effect here too, not just for the megapixel case. **Setup:** generate a real, Pillow-decodable PNG that stays under the megapixel limit but is large in bytes — noise data compresses poorly, so this reliably produces a large file:
                uv run python3 -c "
                import os
                from PIL import Image
                w, h = 4800, 5000  # 24.0 megapixels — under the 25MP pre-check
                img = Image.frombytes('RGB', (w, h), os.urandom(w*h*3))
                img.save('/tmp/qa-bigfile.png', compress_level=1)
                "
                
                (Produces a ~75MB file, safely over Google's documented 50MB ceiling while staying under the megapixel pre-check.) **Prompt** > "In doc {DOC_ID}, insert local images: marker 'IMGMARKERBIG', local_path '/tmp/qa-bigfile.png', into folder {FOLDER_ID}" Tool call: `insert_local_images(doc_id={DOC_ID}, images=[{"marker": "IMGMARKERBIG", "local_path": "/tmp/qa-bigfile.png"}], folder_id={FOLDER_ID})` **Checks** - `results[0]` has **no** `fileId` (pre-validation now catches it *before* any upload) and an `error` naming the file's actual size in MB and Google's 50MB limit (`too_large_bytes_message` — mirrors `too_large_message`'s megapixel-case wording, including the `auto_downscale=True` suggestion) - No new file appears in `{FOLDER_ID}` — confirm via `list_files(folder_id={FOLDER_ID})` or equivalent, since nothing should have been uploaded - `get_doc_structure` shows the "IMGMARKERBIG" marker text still present, unchanged **Follow-up check (`auto_downscale=True`):** re-run the same call with `auto_downscale=True` added — `results[0]` should now succeed with `downscaled: true` and a `fileId`, and the uploaded copy's byte size should be at or under 50MB (the megapixel-only downscale path this call would have hit pre-#562 was a no-op here, since 24MP is already under the 25MP limit — this confirms the new byte-size-driven shrink loop in `downscale_image_bytes` is what actually did the work). **Cleanup:** if `auto_downscale=True` was run, trash the uploaded file (`results[0].fileId`); write fixture content back over `{DOC_ID}` **Result (2026-08-09) ✅ PASS — run live against PR #554 round 2 (fix commit badad67, issue #400). Predates #562 — see the note in Background above; this result documents the pre-#562 rewritten-error batchUpdate-fallback behavior that #562's pre-validation now supersedes for this specific case.** First attempt (immediately after a reconnect that had happened *before* the worktree was synced to `badad67`) reproduced the exact stale-reconnect trap this project's QA process has hit before — the error came back unrewritten. A second reconnect (after confirming the worktree was already on the fix commit) plus a retry got the correctly rewritten error: `doc edit failed: This is very likely Google Docs' inline-image limit of 25 megapixels (...) — check the image's pixel dimensions and resize it before retrying.` Marker text confirmed unchanged after the failed call. **Result (2026-08-12) ✅ PASS — run live against PR #580 (issue #562, commit `048fc97`), both the main case and the `auto_downscale=True` follow-up.** Generated a real 4900x4900 (24.01MP, under the 25MP pre-check) noise PNG at ~76.0MB. Main call (`auto_downscale` omitted): `results[0]` had no `fileId` and `error` = `"Image is 76.0MB, which exceeds Google Docs' inline-image file-size limit of 50MB (...). Resize it before inserting, or pass auto_downscale=True to have it resized automatically."` — confirmed via `list_files(folder_id={FOLDER_ID}, mime_type="image/png")` that nothing was uploaded, and `get_doc_structure` showed the marker text unchanged. Follow-up call with `auto_downscale=True`: succeeded — `results[0]` had `fileId`, `downscaled: true`, `shared: false`, and an `index`; `get_file_metadata` on the uploaded file showed `size: "47357544"` (~47.4MB, under the 50MB ceiling); `get_doc_structure` confirmed the marker text was replaced. This live-verifies the new byte-size-driven shrink loop in `downscale_image_bytes` actually converges for a realistic low-compressibility image, one of this round's code-review findings ([current round's code review](https://github.com/khuisman/mcp-gee-sweet/pull/580) — the loop has no postcondition check if it doesn't converge within its bounded attempts, filed as a non-blocking follow-up since this real-world case converges on the very first resize). Uploaded file trashed and fixture doc content restored after. **Result (2026-09-04) ✅ PASS** Generated real ~76MB 4800x5000 (24.0MP, under 25MP pre-check) noise PNG. Main call (no auto_downscale): results[0] no fileId, error = "Image is 76.0MB, which exceeds ... 50MB limit ... auto_downscale=True" — pre-validation caught it before any upload; list_files confirmed nothing uploaded; marker unchanged. Follow-up call with auto_downscale=True: succeeded, fileId, downscaled:true; uploaded copy's size 47,361,943 bytes (~45.2MB), under 50MB — confirms the byte-size-driven shrink loop converges. Uploaded file trashed, fixture doc restored to seed state. --- ## `update_doc_from_file` (#341) `update_doc_from_file(doc_id, local_path, content_format=None, ...)` combines `create_doc_from_file`'s server-side file reading (extension inference, same error shapes) with `write_doc_content`'s in-place clear+replace mechanism — both now share one implementation via `_replace_doc_content`. Like `write_doc_content`/`insert_doc_text`/`style_doc_range`, the tool itself is auth-agnostic (it never creates a Drive file, only edits an existing doc's content) — none of the cases below are tagged `⚠️ requires-oauth`, matching those siblings' own convention (see the note at line 2053 above). All cases operate directly on the shared fixture doc `{DOC_ID}`, the same convention `write_doc_content`'s own TC-DOC39–43 use. ### TC-DOC168: `update_doc_from_file` replaces `{DOC_ID}`'s content in place from a local .md file ⚠️ destructive **Setup:** `get_file_metadata(file_id={DOC_ID})`, note `name`/`parents`/`webViewLink` for comparison after **Prompt** > "Update Google Doc {DOC_ID} from the file /docs/qa/fixtures/tc-d195-create-doc.md" **Checks** - Returned `docId` equals `{DOC_ID}`, `web_link` present, no `error` - `get_doc_structure` shows HEADING_1 "QA Test Document", bold/italic runs, `☑`/`☐` bullet items, and the Col A/Col B table from the fixture — matching what TC-DOC44 confirms `create_doc_from_file` produces from the same file - `get_file_metadata(file_id={DOC_ID})` afterward shows the same `name`/`parents`/`webViewLink` as the Setup snapshot — confirms the doc's identity and Drive location were preserved (no new Doc was minted), only its content changed **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-10) ✅ PASS — run live against PR #564 (issue #341).** `update_doc_from_file` returned `docId` matching `{DOC_ID}` and a `web_link`, no `error`. `get_doc_structure` afterward showed HEADING_1 "QA Test Document", bold "bold"/italic "italic" runs, `☑ Task complete`/`☐ Task pending` bullet items, and the Col A/Col B table — matches TC-DOC44's own `create_doc_from_file` output for the same fixture file. `get_file_metadata` before and after showed identical `name`/`parents`/`webViewLink` — doc identity and location preserved. **Result (2026-09-04) ✅ PASS** update_doc_from_file(tc-d195-create-doc.md) on DOC_ID: returned docId matching DOC_ID, no error. get_doc_structure matches TC-DOC44's create_doc_from_file output exactly (HEADING_1, bold/italic runs, ☑/☐ items, Col A/Col B table). get_file_metadata before/after identical name/parents/webViewLink — doc identity/location preserved. ### TC-DOC169: `update_doc_from_file` with a local .html file ⚠️ destructive **Prompt** > "Update Google Doc {DOC_ID} from the file /docs/qa/fixtures/tc-d196-create-doc.html" **Checks** - Returned `docId` equals `{DOC_ID}`, no `error` - `get_doc_structure` shows HEADING_2 "From HTML file" and paragraph "Content paragraph." — matching what TC-DOC45 confirms for the same file via `create_doc_from_file` **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-10) ✅ PASS — run live against PR #564 (issue #341).** Returned `docId` matching `{DOC_ID}`, no `error`. `get_doc_structure` afterward showed HEADING_2 "From HTML file" and paragraph "Content paragraph." — matches TC-DOC45's own `create_doc_from_file` output for the same fixture file. **Result (2026-09-04) ✅ PASS** update_doc_from_file(tc-d196-create-doc.html): docId matched, no error. get_doc_structure: HEADING_2 "From HTML file", paragraph "Content paragraph." — matches TC-DOC45. ### TC-DOC170: `update_doc_from_file` file not found **Prompt** > "Update Google Doc {DOC_ID} from the file ~/does-not-exist.md" **Checks** - Returns `{"error": "File not found: ..."}` — no exception raised - Fixture doc `{DOC_ID}` is left completely untouched (`get_doc_content` unchanged) — the file-existence check runs before any Docs API call **Result (2026-08-10) ✅ PASS — run live against PR #564 (issue #341).** Returned `{"error": "File not found: ~/does-not-exist.md"}`, no exception. `get_doc_content` before and after the call returned byte-identical content (including headers/footers) and the same `modified_time`. **Result (2026-09-04) ✅ PASS** update_doc_from_file('~/does-not-exist.md') returned {"error":"File not found: ~/does-not-exist.md"}, no exception. get_doc_content confirmed doc unchanged (still HEADING_2/paragraph from DOC169). ### TC-DOC171: `update_doc_from_file` unsupported extension with no override returns an error, doc untouched **Setup:** create a local file `qa-update.txt` with arbitrary plain text **Prompt** > "Update Google Doc {DOC_ID} from the file /qa-update.txt" **Checks** - Returns `{"error": "Unsupported file extension '.txt'. ..."}` mentioning `.txt` and that `content_format` can be passed explicitly - Fixture doc `{DOC_ID}` is left untouched (no Docs API call made — the extension check runs before the doc_id lookup, same as the file-not-found case above) **Cleanup:** delete the local `qa-update.txt` file **Result (2026-08-10) ✅ PASS — run live against PR #564 (issue #341).** Returned `{"error": "Unsupported file extension '.txt'. Use .md or .html/.htm, or pass content_format explicitly."}`. `get_doc_content` before and after the call was byte-identical with the same `modified_time` — no Docs API call was made. **Result (2026-09-04) ✅ PASS** update_doc_from_file(qa-update.txt, no override) returned {"error":"Unsupported file extension '.txt'. Use .md or .html/.htm, or pass content_format explicitly."}. get_doc_content confirmed doc untouched — no Docs API call made. ### TC-DOC172: `update_doc_from_file` `content_format` explicitly overrides an unrecognized extension ⚠️ destructive **Setup:** create a local file `qa-update-override.txt` containing `# Overridden Heading\n\nParagraph text.\n` **Prompt** > "Update Google Doc {DOC_ID} from the file /qa-update-override.txt with content_format='markdown'" **Checks** - No `error`; `get_doc_structure` shows HEADING_1 "Overridden Heading" and paragraph "Paragraph text." **Cleanup:** write fixture content back over `{DOC_ID}`; delete the local `qa-update-override.txt` file **Result (2026-08-10) ✅ PASS — run live against PR #564 (issue #341).** Called with `content_format='markdown'` on `qa-update-override.txt`; no `error`. `get_doc_structure` afterward showed HEADING_1 "Overridden Heading" and paragraph "Paragraph text." Fixture content restored and local file deleted afterward. **Result (2026-09-04) ✅ PASS** update_doc_from_file(qa-update-override.txt, content_format='markdown') succeeded, no error. get_doc_structure: HEADING_1 "Overridden Heading", paragraph "Paragraph text." Fixture restored after. --- ## `get_doc_as_markdown` ### TC-DOC173: Basic export — headings, styled runs, links **Setup:** `write_doc_content(doc_id={DOC_ID}, content_format='markdown', content="# Title\n\nSome **bold** and *italic* and ~~strike~~ text with a [link](https://example.com).\n")` **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - Returns `doc_id`, `title`, and `markdown` - `markdown` contains `# Title` - `markdown` contains `**bold**`, `*italic*`, `~~strike~~`, and `[link](https://example.com)` **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300).** `markdown` returned `"# Title\n\nSome **bold** and *italic* and ~~strike~~ text with a [link](https://example.com).\n\n"` — all checks satisfied. **Result (2026-09-04) ✅ PASS** get_doc_as_markdown basic export: returned doc_id/title/markdown = "# Title\n\nSome **bold** and *italic* and ~~strike~~ text with a [link](https://example.com).\n\n" — exact match. --- ### TC-DOC174: Nested, ordered, and checked bullet lists **Setup:** `write_doc_content(doc_id={DOC_ID}, content_format='markdown', content="- top\n - nested\n- [x] done\n- [ ] todo\n\n1. first\n2. second\n")` **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` shows "nested" indented under "top" (e.g. two leading spaces before its `- ` marker) - `markdown` contains `- [x] done` and `- [ ] todo` - `markdown` contains an ordered-list marker (`1. `) for both "first" and "second" - No blank line between consecutive list items (a "tight" list) **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300).** `markdown` returned `"- top\n - nested\n- [x] done\n- [ ] todo\n1. first\n1. second\n\n"`. All stated checks satisfied literally (both ordered items are marked `1. ` — valid CommonMark, since ordered-list rendering is driven by the first item's number, not a defect). Note for a future test-case tightening pass (not filed as a ticket — matches its own written check): no blank line separates the unordered and ordered lists on the round-trip, unlike the source's blank line. **Result (2026-09-04) ✅ PASS** Nested/ordered/checked lists export: markdown = "- top\n - nested\n- [x] done\n- [ ] todo\n1. first\n1. second\n\n" — nested indented under top, checkboxes correct, ordered markers present (both "1." — valid CommonMark, numbering driven by first item), tight list (no blank line between items). --- ### TC-DOC175: Blockquote nesting **Setup:** `write_doc_content(doc_id={DOC_ID}, content_format='markdown', content="> a quoted line\n>> double quoted\n")` **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` contains a line prefixed `> ` for "a quoted line" - `markdown` contains a line prefixed `> > ` for "double quoted" **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300).** `markdown` returned `"> a quoted line\n\n> > double quoted\n\n"` — both checks satisfied. **Result (2026-09-04) ✅ PASS** Blockquote nesting export: markdown contains "> a quoted line" and "> > double quoted". --- ### TC-DOC176: Inline code vs. fenced code block **Setup:** `write_doc_content(doc_id={DOC_ID}, content_format='markdown', content="See `x` inline.\n\n```\nfull code block\n```\n")` **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` contains `` `x` `` inline within the "See ... inline." sentence (not as its own fenced block) - `markdown` contains a fenced block (triple backtick) wrapping "full code block" **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300).** `markdown` returned `"See \`x\` inline.\n\n\`\`\`\nfull code block\n\`\`\`\n\n"` — both checks satisfied. **Result (2026-09-04) ✅ PASS** Inline code vs fenced block: markdown = "See \`x\` inline.\n\n\`\`\`\nfull code block\n\`\`\`\n\n" — exact match. --- ### TC-DOC177: Table with a merged (colspan) header cell **Setup:** raw HTML via `write_doc_content(doc_id={DOC_ID}, content_format='html', content="
                Merged
                ab
                ")` **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` contains a pipe-table with a header row, a `| --- | --- |` separator, and a data row `| a | b |` - The merged cell's text ("Merged") appears in the first column of the header row; the second header column is blank **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ❌ FAIL — run live against PR #591 (issue #300), round 1.** `markdown` returned `"\n\n| Merged | | |\n| --- | --- | --- |\n| a | b | |\n\n"` — a **3-column** table instead of 2. A phantom placeholder column appears from the colspan merge. Confirms `/code-review high`'s finding on `doc_to_ast.py`'s `_table_elem_to_ast` (builds one `Cell` per raw `tableCells[]` JSON entry unconditionally, not accounting for the phantom placeholder entries Google's API leaves for positions covered by an earlier cell's `rowSpan`/`columnSpan` — a fact this codebase's own `emitter.py` already established). Additional live probe (rowspan, not in this test case but same root cause) is worse: `
                Tallb1
                b2
                ` exported as `"| Tall | b1 |\n| --- | --- |\n| | |\n\n"` — **`b2`'s content is silently lost entirely**, not just misaligned. Blocking finding; commented on PR, handed back to Jay. **Result (2026-08-15) ✅ PASS — re-verified live against PR #591 (issue #300) fix commit 4adeb03, round 2.** `markdown` returned `"\n\n| Merged | |\n| --- | --- |\n| a | b |\n\n"` — correct 2-column table, no phantom column. Fixed via `doc_to_ast.py`'s new `covered` position-tracking in `_table_elem_to_ast`. **Result (2026-09-04) ✅ PASS** Colspan header table export: markdown = 2-column table "| Merged | |\n| --- | --- |\n| a | b |" — correct, no phantom column (fix still holds). --- ### TC-DOC178: Nested table renders a placeholder, not silently dropped **Setup:** raw HTML via `write_doc_content(doc_id={DOC_ID}, content_format='html', content="
                outer text
                inner
                ")` **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` contains "outer text" in the corresponding cell - That same cell contains a placeholder phrase (e.g. "nested table omitted") rather than silently losing the inner table's content with no trace **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300).** `markdown` returned `"\n\n| outer text*(nested table omitted — Markdown tables can't contain a table; use get_doc_structure for full fidelity)* |\n| --- |\n\n"` — both checks satisfied. Minor cosmetic nit (not filed): no space/newline between "outer text" and the placeholder note, so they run together. **Result (2026-09-04) ✅ PASS** Nested table export: markdown contains "outer text" and placeholder "*(nested table omitted — Markdown tables can't contain a table; use get_doc_structure for full fidelity)*" in the same cell. --- ### TC-DOC179: `include_comments=True` includes only open comments **Setup:** `write_doc_content(doc_id={DOC_ID}, content_format='markdown', content="Some anchor text here.\n")`, then `add_doc_comment(doc_id={DOC_ID}, content="please revise", quoted_text="anchor text")` to get `comment_id_1`, and a second `add_doc_comment(doc_id={DOC_ID}, content="resolved note")` to get `comment_id_2`, then `resolve_doc_comment(doc_id={DOC_ID}, comment_id=comment_id_2)` **Prompt** > "Export doc {DOC_ID} as Markdown including comments" **Checks** - `markdown` contains a `## Comments` section - "please revise" and the quoted anchor "anchor text" appear - "resolved note" does NOT appear (resolved comments are excluded) **Cleanup:** write fixture content back over `{DOC_ID}` (this also clears the comments' anchor text, but the comments themselves persist on the file — delete via Drive UI if a clean fixture is required for a later run) **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300).** Both checks satisfied: "please revise" and quoted "anchor text" present, "resolved note" absent. An unrelated stale open comment ("QA TC-DOC97: anchored note") from earlier fixture pollution also appeared in the section — pre-existing, already tracked under #304, not caused by this PR. **Result (2026-09-04) ✅ PASS** include_comments=True: markdown contains "## Comments", "please revise" + quoted "anchor text" present, "resolved note" absent (resolved excluded). Note: an unrelated stale open comment from this same shard's earlier TC-DOC97 run also appeared — pre-existing fixture-doc comment pollution, not a defect. --- ### TC-DOC180: `include_comments` omitted defaults to no Comments section **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` does NOT contain `## Comments`, even if the doc has comments from a prior test **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300).** `markdown` returned `"Some anchor text here.\n\n"` — no `## Comments` section, despite open comments still on the doc from TC-DOC179. **Result (2026-09-04) ✅ PASS** include_comments omitted: markdown has no "## Comments" section despite open comments existing on the doc. --- ### TC-DOC181: Invalid doc_id returns an error, not a crash **Prompt** > "Export doc nonexistent-doc-id-xyz as Markdown" **Checks** - Returns `{"error": ...}` — no traceback surfaced to the caller **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300).** Returned `{"error": ""}`, no traceback. **Result (2026-09-04) ✅ PASS** get_doc_as_markdown('nonexistent-doc-id-xyz') returned clean {"error": ""}, no traceback. --- ### TC-DOC182: `local_path` bypasses the response and writes to disk **Prompt** > "Export doc {DOC_ID} as Markdown, writing the result to /qa-md-export.json" **Checks** - Returns `{local_path, doc_id, bytes_written}` (no inline `markdown` field in the response) - The file at `/qa-md-export.json` exists and its `markdown` field round-trips the doc's actual content **Cleanup:** delete the local `qa-md-export.json` file **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300).** Response was `{"local_path": ..., "bytes_written": 170, "doc_id": ...}` with no inline `markdown` field; the written file's `markdown` field matched the doc's actual content exactly. **Result (2026-09-04) ✅ PASS** local_path set: response {local_path, doc_id, bytes_written}, no inline markdown field. File content's markdown field matched doc's actual content exactly. Local file cleaned up. --- ### TC-DOC183: Rowspan merge preserves the covered row's own trailing cell **Setup:** raw HTML via `write_doc_content(doc_id={DOC_ID}, content_format='html', content="
                Tallb1
                b2
                ")` **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` contains a 2-column pipe table: header row `| Tall | b1 |`, then `| --- | --- |`, then a data row whose second column is `b2` (first column blank — the position "Tall" spans into) - `b2` is NOT dropped — this reproduces PR #591 QA round 1's root-cause finding (rowspan/multi-row tables previously had zero test coverage and silently lost the covered row's own real cell) **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300) fix commit 4adeb03, round 2.** `markdown` returned `"\n\n| Tall | b1 |\n| --- | --- |\n| | b2 |\n\n"` — `b2` preserved in the second column, first column blank. **Result (2026-09-04) ✅ PASS** Rowspan export: markdown = "| Tall | b1 |\n| --- | --- |\n| | b2 |\n\n" — b2 preserved in second column. --- ### TC-DOC184: Link URL containing unbalanced parentheses doesn't break the destination **Setup:** raw HTML via `write_doc_content(doc_id={DOC_ID}, content_format='html', content="

                link

                ")` **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` contains `[link]()` — the destination is angle-bracket wrapped, not a bare `(...)` that breaks on the unmatched `)` **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300) fix commit 4adeb03, round 2.** `markdown` returned `"[link]()\n\n"` — exact match. Regression check: a plain URL with no parens/whitespace (TC-DOC173's `https://example.com`) re-verified unaffected — still renders as a bare, unwrapped destination. **Result (2026-09-04) ✅ PASS** Link with unbalanced parens: markdown = "[link]()\n\n" — angle-bracket wrapped, exact match. --- ### TC-DOC185: Plain paragraph text resembling a block marker is escaped, not reinterpreted **Setup:** raw HTML via `write_doc_content(doc_id={DOC_ID}, content_format='html', content="

                1. Not actually a list item

                # Not a heading either

                ")` **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` contains `1\. Not actually a list item` (backslash before the period — not a real ordered-list marker) - `markdown` contains `\# Not a heading either` (backslash before the hash — not a real ATX heading) **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #591 (issue #300) fix commit 4adeb03, round 2.** `markdown` returned `"1\\. Not actually a list item\n\n\\# Not a heading either\n\n"` — both leading markers escaped as expected. **Result (2026-09-04) ✅ PASS** Plain text resembling markers: markdown = "1\\. Not actually a list item\n\n\\# Not a heading either\n\n" — both escaped correctly. --- ### TC-DOC186: Image mixed into an all-Courier-New paragraph is not dropped (#594) **Setup:** `write_doc_content(doc_id={DOC_ID}, content_format='html', content="

                \"testx = 1

                ")` — note `` (not ``), and the image outside `
                `: html_parser.py's write side silently drops an `` inside `
                ` (a deliberate, documented gap — see `docs/images.py`'s module docstring), and `style_doc_range` has no `font_family` param at all, so `` is the only reachable way to get an all-Courier-New paragraph with an image in it through this project's own tools.
                
                **Prompt**
                > "Export doc {DOC_ID} as Markdown"
                
                **Checks**
                - `markdown` contains an image reference `![...](...)` for the image — not silently dropped (alt text will round-trip empty regardless of the source `...` value — a pre-existing, unrelated gap tracked as #508, since `insertInlineImage`'s write path never stamps `title`/`description` on the embedded object; not this test's concern)
                - `markdown` also contains a fenced block (triple backtick) wrapping `x = 1`
                - Both appear in the output, image before the fenced block (source order)
                
                **Cleanup:** write fixture content back over `{DOC_ID}`
                
                **Result (2026-08-15) ✅ PASS — run live against PR #599 (issue #594).** `markdown` returned `"![](https://lh7-rt.googleusercontent.com/...)\n\n```\nx = 1\n```\n\n"` — image present and ordered before the fenced code block as expected; alt text came back empty (`![]`, not `![test image]`) due to #508, unrelated to this PR's own fix.
                
                **Result (2026-09-04) ✅ PASS**
                Image + code in all-Courier-New paragraph: markdown contains an image reference followed by a fenced code block wrapping "x = 1", image before code block (source order) — not dropped.
                
                ---
                
                ### TC-DOC187: Blank spacer paragraph inside a table cell is preserved, not collapsed (#594)
                **Setup:** raw HTML via `write_doc_content(doc_id={DOC_ID}, content_format='html', content="
                Line 1

                Line 2
                ")` — two consecutive `
                ` insert a literal `"\n\n"` into the cell's body text, which the Docs backend splits into three real paragraphs in the cell (`"Line 1"`, an empty one, `"Line 2"`) — this is the only reachable way through this project's write tools to produce a genuinely empty-runs paragraph (as opposed to a ` `-only one, which round-trips as non-empty per #402) inside a table cell. **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown` contains the cell's content as `Line 1

                Line 2` (double `
                ` — the blank spacer survives as a second line break) - NOT `Line 1
                Line 2` (single `
                `) or `Line 1 Line 2` (no separator at all) — either would mean the spacer paragraph was silently dropped **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #599 (issue #594).** `markdown` returned `"\n\n| Line 1

                Line 2 |\n| --- |\n\n"` — double `
                ` preserved, spacer not collapsed, for the mid-cell case this test covers. ⚠️ **Gap found in the same fix, not covered by this test case:** a spacer at a cell's *leading or trailing* edge (rather than sandwiched between two real lines) is still silently dropped. Reproduced live: `write_doc_content(content="
                Trailing test



                Leading test
                ")` → `get_doc_as_markdown` returned `"\n\n| Trailing test |\n| --- |\n| Leading test |\n\n"` — no `

                ` in either row, indistinguishable from a cell with no spacer at all. Root cause: `_cell_content_to_children` (doc_to_ast.py) correctly represents the edge spacer as a leading/trailing `"\n"` in the cell's own text (confirmed via local AST inspection: `'Line 1\n'` / `'\nLine 2'`), but `_render_cell` (ast_to_markdown.py, unmodified by this PR) does `text = "".join(parts).strip()` before the `\n`→`
                ` conversion — `.strip()` removes exactly the leading/trailing newline this PR's own fix just added, so only a mid-cell spacer survives to the rendered Markdown. Reported as a blocking finding on PR #599 rather than fixed here directly, since it needs a code change in the same fix this PR introduces. Fix (round 2): `_render_cell` now converts `\n` → `
                ` *before* calling `.strip()`, not after — `
                ` isn't whitespace, so a genuine edge spacer survives the strip while incidental surrounding whitespace still gets trimmed as before. See TC-DOC188 below for the dedicated edge-spacer test case this gap was missing. **Result (2026-09-04) ✅ PASS** Mid-cell blank spacer (double
                ): markdown = "| Line 1

                Line 2 |\n| --- |\n\n" — double
                preserved, spacer not collapsed. --- ### TC-DOC188: Blank spacer paragraph at a cell's leading/trailing edge is preserved (#594 round 2) **Setup:** raw HTML via `write_doc_content(doc_id={DOC_ID}, content_format='html', content="
                Trailing test

                Leading test
                ")` — a single `
                ` at each edge, matching the single-spacer shape the unit tests (`test_trailing_spacer_paragraph_in_a_cell_leaves_a_trailing_newline` et al.) model; using `

                ` here (as the original PR-comment repro and QA round 1's gap report did) inserts two literal `"\n"` characters, which the Docs backend splits into *two* blank paragraphs at the edge, not one — live-confirmed to render as a doubled `

                ` rather than the single `
                ` these checks describe. Corrected during round-2 QA re-verification. **Prompt** > "Export doc {DOC_ID} as Markdown" **Checks** - `markdown`'s first data row renders as `| Trailing test
                |` (the trailing spacer survives as a trailing `
                `, not dropped to a bare `| Trailing test |`) - `markdown`'s second data row renders as `|
                Leading test |` (the leading spacer survives as a leading `
                `, not dropped to a bare `| Leading test |`) **Cleanup:** write fixture content back over `{DOC_ID}` **Result (2026-08-15) ✅ PASS — run live against PR #599 (issue #594) round 2, fix commit 42ed950.** With the corrected single-`
                `-per-edge setup, `markdown` returned `"\n\n| Trailing test
                |\n| --- |\n|
                Leading test |\n\n"` — matches both checks exactly. Also re-confirmed with the original (double-`
                `) setup as a bonus check: returned `"\n\n| Trailing test

                |\n| --- |\n|

                Leading test |\n\n"` — 2 `
                ` in, 2 `
                ` out, consistent with the 1:1 preservation the mid-cell TC-DOC187 case already established; confirms the fix isn't collapsing multi-paragraph edge spacers either, just previously-reported-as-single-`
                `-expected case was actually a two-paragraph input. **Result (2026-09-04) ✅ PASS** Edge spacers (single
                each): markdown = "| Trailing test
                |\n| --- |\n|
                Leading test |\n\n" — both leading/trailing spacers survive exactly. --- ## Orphan fileId on inline-image sharing failure (#649) Mirrors #420's fix in `drive/transfer.py` (see TC-D249/TC-D250 in `docs/qa/tests/drive_transfer.md`) applied to the same create()-succeeds-but-follow-up-fails shape in `docs/images.py`'s `upload_and_share_image` and `docs/content.py`'s `_resolve_image_source`: a transient failure in the `permissions().create()`/`files().get()` sharing step, after `files().create()` (or an already-uploaded `_upload_local_file`) had already succeeded, previously returned a bare `{"error": ...}` with no way to find the Drive file that now genuinely exists. Not reliably reproducible live (would require forcing a transient API failure in the exact window between create and share); verified by unit test instead, matching TC-D249/TC-D250's own convention. ### TC-DOC189: `upload_and_share_image` / `_resolve_image_source` — a sharing-step failure after a successful upload reports the orphan's file_id, not a bare error (unit test) **Background:** three call sites had this shape: `images.py`'s `upload_and_share_image` (used directly by `insert_local_images`'s auto_downscale branch and by `downscale_drive_file`), `images.py`'s `insert_local_images`'s own plain (non-downscaled) upload+share step, and `content.py`'s `_resolve_image_source`'s shared sharing step (used by `create_doc`/`create_doc_from_file`/`write_doc_content`/`update_doc_from_file`'s markdown/HTML image embedding for both `drive:` and local-path sources). **Checks (unit test)** - `tests/test_docs_images.py::TestUploadAndShareImage::test_share_failure_after_create_returns_file_id` — `create()` succeeds, `permissions().create()` raises → result carries `file_id` alongside `error` - `tests/test_docs_images.py::TestUploadAndShareImage::test_metadata_fetch_failure_after_create_returns_file_id` — `create()` and `permissions().create()` succeed, `files().get()` raises → result still carries `file_id` - `tests/test_docs_images.py::TestUploadAndShareImage::test_create_failure_returns_bare_error_no_file_id` — regression guard: when `create()` itself fails, no `file_id` key at all (nothing was created, so there's no orphan) - `tests/test_docs_images.py::TestInsertLocalImages::test_sharing_failure_reports_per_image_error_and_skips_doc_edit` (updated) and `test_downscaled_upload_share_failure_still_reports_file_id` (new) — both of `insert_local_images`'s upload paths (plain and auto_downscale) surface `fileId` in the per-image outcome entry on a sharing failure - `tests/test_docs_images.py::TestInsertLocalImages::test_missing_web_content_link_after_share_still_reports_orphan_file_id` (new, PR #652 QA round 1 finding 1) — `insert_local_images`'s plain path's *second* post-upload failure branch (`if not uri:` — upload and share both succeeded, webContentLink read-back empty) also surfaces `fileId` in the per-image outcome and still marks the folder cache dirty - `tests/test_docs_images.py::TestInsertLocalImages::test_sharing_failure_orphan_still_marks_folder_cache_dirty` / `test_downscaled_upload_share_failure_still_reports_file_id` (cache assertion) — the folder-listing cache is still marked dirty for the orphan case, mirroring the cache-invalidation-gate fix PR #645's QA round found necessary for the identical shape in `transfer.py` - `tests/test_docs_content.py::TestResolveImageSource::test_local_upload_sharing_failure_returns_orphan_file_id` (new) — a local-path source's sharing failure (the true orphan case — the file was freshly created by this call) carries `file_id` - `tests/test_docs_content.py::TestResolveImageSource::test_local_upload_missing_web_content_link_returns_orphan_file_id` (new) — same local-path source, the missing-`webContentLink` branch, also carries `file_id` - `tests/test_docs_content.py::TestResolveImageSource::test_sharing_failure_is_error` (updated, PR #652 QA round 1 finding 2) and `test_missing_web_content_link_is_error` (updated) — a `drive:` source is the caller's *pre-existing* file, not an orphan this call created, so both its post-upload failure branches now **omit** `file_id` — surfacing it in a failed-image outcome would let a caller with orphan-reclaim logic delete a file it never made - `tests/test_docs_content.py::TestCreateDocImages::test_sharing_failure_image_outcome_still_carries_file_id` (updated — now a local-upload source) — `create_doc`'s own image-outcome assembly loop (which previously only copied `result["error"]`, discarding every other key) surfaces `fileId` in the per-image outcome entry for a genuine orphan - `tests/test_docs_content.py::TestCreateDocImages::test_drive_source_sharing_failure_omits_file_id` (new) — the `drive:` counterpart: `create_doc`'s image outcome for a `drive:` source's sharing failure has no `fileId` **Result: N/A — unit-test-verified only, per TC-D249/TC-D250 convention** (the transient-failure window between create and share cannot be forced against the live API). All listed unit tests run green locally (`uv run python -m pytest tests/test_docs_images.py tests/test_docs_content.py` → 264 passed, 2026-08-28). **Result (2026-08-28) ❌ FAIL — first QA pass on PR #652, Sky.** `/code-review high origin/develop...HEAD` surfaced a blocking gap the unit suite does not cover: `insert_local_images._upload_and_share`'s plain (non-downscale) upload path has a *second* post-upload failure branch — `if not uri:` (metadata fetched but Drive returned no `webContentLink`, `images.py:722`) — that was **not** given the #649 fix its two sibling branches received in this same diff (`upload_and_share_image` `images.py:301-305`, `_resolve_image_source` `content.py:277-281`, both carry `file_id`). It sets `entry["error"]` + `placement["failed"] = True` and returns without `entry["fileId"]` / `placement["file_id"]`, so the orphan is unreported *and* the `any("file_id" in p ...)` folder-cache-dirty gate at `images.py:746` never fires for it — the exact class #649 exists to close, left unfixed in 1 of the 3 enumerated call sites. Needs the `file_id` propagation plus a unit test mirroring `test_missing_web_content_link_is_error`. Sent back to Ash; see PR comment for the full finding list (incl. the `drive:` vs local-upload orphan-shape ambiguity, and non-blocking tickets/comments filed for the systemic dedup + cleanup gaps). **Result (2026-08-28) ✅ PASS — re-verification round on PR #652, Sky, fix commit `c4e5f01`.** Both blocking findings closed: - **Finding 1:** `insert_local_images._upload_and_share`'s plain-path `if not uri:` branch (`images.py:721`) now sets `entry["fileId"]` + `placement["file_id"]` before returning, matching its two sibling branches; new `test_missing_web_content_link_after_share_still_reports_orphan_file_id` asserts both the surfaced `fileId` and the `mark_dirty` call. - **Finding 2:** `_resolve_image_source` gained a `created_here` flag (set `True` only after `_upload_local_file` succeeds); both post-share failure returns now carry `file_id` **only** for the local-upload branch, so a `drive:` source's sharing failure returns a bare error. Covered by updated `test_sharing_failure_is_error` / `test_missing_web_content_link_is_error` (now assert `"file_id" not in result`), new `test_local_upload_missing_web_content_link_returns_orphan_file_id`, and new `test_drive_source_sharing_failure_omits_file_id`. Verification: fix diff is tightly scoped to the two named findings (no `/code-review` re-run needed), full docs unit suite green (`uv run python -m pytest tests/test_docs_images.py tests/test_docs_content.py` → **267 passed**), and a live happy-path smoke (`mcp-gee-sweet-sky` `create_doc` with a local-image markdown embed into the QA fixtures folder) returned `images:[{src, fileId, shared:false}]` with no error — the `_resolve_image_source` refactor doesn't regress the common embed path. Smoke doc + uploaded image deleted afterward. **Result (2026-09-04) ➖ N/A** ---