Skip to content

fix: keep surrogate pairs and emoji whole when truncating titles, names and bodies - #3215

Merged
OrKoN merged 5 commits into
ChromeDevTools:mainfrom
kevin9327:fix/truncate-surrogate-pairs
Oct 20, 2026
Merged

OrKoN merged 5 commits into
ChromeDevTools:mainfrom
kevin9327:fix/truncate-surrogate-pairs

Conversation

@kevin9327

@kevin9327 kevin9327 commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3214.

Three places cut strings by UTF-16 code units: truncateTitle for page titles in the page list (text and structured output), truncateValue for heap-snapshot names, and getSizeLimitedString for request/response bodies and URLs. When an emoji or other astral character straddles the cut, the result keeps a lone high surrogate. JSON.stringify then emits it as \ud83d, which strict JSON parsers reject, including the Pydantic-based one the Python MCP SDK uses. The whole tool response fails to parse on the client.

This adds a shared truncateText() helper in src/utils/text.ts, and all three call sites use it. maxLength stays a UTF-16 code-unit bound. The helper moves the cut back to the last grapheme boundary (Intl.Segmenter) at or before the limit, so surrogate pairs and emoji sequences stay whole and the output is never longer than the limit plus the ellipsis. Strings that do not split a grapheme truncate exactly as before.

Tests:

  • tests/utils/text.test.ts (new): a pair straddling the limit, code-unit counting, a 20 000-family-emoji body that stays within 10 000 units, joined emoji sequences, and edge-case limits
  • HeapSnapshotFormatter: formatName with an emoji at MAX_NAME_LENGTH
  • NetworkFormatter: a URL with an emoji straddling the 255-unit limit
  • McpResponse: a mock page whose title is the repro from the issue (46 As plus an emoji). The listed title ends AAA... and the serialized response contains no \ud83d.

The formatter and response tests fail on main and pass with this change. tsc and eslint are clean.

AI-assisted: I used an AI assistant to help write this change. I reviewed the code and tests and ran them locally.

@OrKoN
OrKoN self-requested a review October 17, 2026 05:08
Comment thread src/McpResponse.ts Resolved
kevin9327 added a commit to kevin9327/chrome-devtools-mcp that referenced this pull request Oct 17, 2026
Address OrKoN review on ChromeDevTools#3215: count graphemes instead of UTF-16 code units so emoji sequences stay intact.
Comment thread src/McpResponse.ts Resolved
kevin9327 added a commit to kevin9327/chrome-devtools-mcp that referenced this pull request Oct 18, 2026
Address review on ChromeDevTools#3215: generalize the Intl.Segmenter truncation from truncateTitle into truncateText() in src/utils/text.ts and use it for page titles, heap snapshot names and network bodies/URLs. Drop sliceWithoutSplittingSurrogates.
@OrKoN
OrKoN self-requested a review October 18, 2026 06:27

@OrKoN OrKoN left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check the following:

  1. In src/utils/text.ts, keep maxLength as a UTF-16 code-unit bound and move the cut back to the last grapheme boundary at or before it, instead of counting graphemes. truncateText now counts graphemes, so BODY_CONTEXT_SIZE_LIMIT = 10000 no longer bounds output: a body of 20 000 family emoji (ZWJ sequences) returns 110 003 code units, and a page can inflate the response by 11x.

  2. Retitle the PR and the commit to match the final behavior, or revert to the code-unit semantics from item 1. The title and description say "keep surrogate pairs whole", but the shipped helper changes what maxLength means for every caller.

  3. In tests/McpResponse.test.ts, replace the real-browser title test with a mock-based one (a createMockMcpPage() whose title() returns the long title, then setIncludePages(true) + handle()). The truncation is pure string logic; AGENTS.md asks for mocks when no browser integration is under test.

Nit (non-blocking): hoist new Intl.Segmenter(undefined, {granularity: 'grapheme'}) to a module-level constant; it is created on every truncating call.

Comment thread src/utils/text.ts Resolved
@kevin9327 kevin9327 changed the title fix: keep surrogate pairs whole when truncating titles, names and bodies fix: keep surrogate pairs and emoji whole when truncating titles, names and bodies Oct 19, 2026
@kevin9327

Copy link
Copy Markdown
Contributor Author

@OrKoN thanks, addressed in 35548d8:

  1. maxLength is a UTF-16 code-unit bound again. truncateText walks the grapheme boundaries and cuts at the last one at or before the limit, so the 20 000-family-emoji body now returns at most 10 000 units plus the ellipsis (there is a unit test for exactly that case).
  2. With the code-unit semantics back, the original "keep surrogate pairs whole" framing holds. I updated the title and description to describe the final helper.
  3. The real-browser title test is now a mock test: a createMockMcpPage() whose getTitle() returns the long title, then setIncludePages(true) + handle().

Nit: the Intl.Segmenter is a module-level constant now.

On StringUtilities.trimEndWithMaxLength: I looked at it, but it counts grapheme clusters (the loop runs maxLength segments), so it has the same unbounded-output problem as item 1, and it hard-codes … while our callers use ... and ... <truncated>. So I kept the small local helper. Happy to switch if you'd prefer it anyway.

@OrKoN

OrKoN commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

@kevin9327 could you please rebase?

@OrKoN
OrKoN self-requested a review October 19, 2026 12:18
Address OrKoN review on ChromeDevTools#3215: count graphemes instead of UTF-16 code units so emoji sequences stay intact.
Address review on ChromeDevTools#3215: generalize the Intl.Segmenter truncation from truncateTitle into truncateText() in src/utils/text.ts and use it for page titles, heap snapshot names and network bodies/URLs. Drop sliceWithoutSplittingSurrogates.
Move the cut back to the last grapheme boundary at or before maxLength
instead of counting grapheme clusters, so the limit still bounds the
output. Hoist the Intl.Segmenter to a module constant and test the
page title truncation with a mock page instead of a real browser.
@kevin9327
kevin9327 force-pushed the fix/truncate-surrogate-pairs branch from 35548d8 to 1c9be67 Compare October 20, 2026 00:20
@kevin9327

Copy link
Copy Markdown
Contributor Author

@OrKoN rebased onto current main (f874aeb); the PR diff now shows only the truncation change. tests/utils/text.test.ts, the formatter tests and tests/McpResponse.test.ts pass locally, and npm run check-format is clean.

@OrKoN
OrKoN enabled auto-merge October 20, 2026 05:56
@OrKoN
OrKoN added this pull request to the merge queue Oct 20, 2026
Merged via the queue into ChromeDevTools:main with commit 058adcf Oct 20, 2026
14 checks passed
github-merge-queue Bot pushed a commit that referenced this pull request Oct 22, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.12.0](chrome-devtools-mcp-v1.11.0...chrome-devtools-mcp-v1.12.0)
(2026-10-22)


### 🎉 Features

* add --maxNavigationSaved flag to configure retained navigation history
([#3018](#3018))
([a580d13](a580d13))
* add Kimi Code plugin manifest
([#3029](#3029))
([cd00b64](cd00b64))
* Analyze heap snapshots without a browser connection
([#3156](#3156))
([78c8320](78c8320))
* **config:** add --watchConfig to reload config file changes
([#3248](#3248))
([b48561f](b48561f))
* **config:** introduce layered ServerConfig for live reload
([#3203](#3203))
([37f1fda](37f1fda))
* **config:** share ReadonlyServerConfig with McpContext and McpPage
([#3204](#3204))
([0813bf3](0813bf3))
* **config:** support reloading blockedUrlPattern and allowedUrlPattern
([#3268](#3268))
([cc850f5](cc850f5))
* **context:** release disabled feature state on config reload
([#3049](#3049))
([c0140d1](c0140d1))
* Paginate by default in heap snapshot tools
([#3201](#3201))
([fb8375b](fb8375b))
* Paginate detailed diff in compare_heapsnapshots
([#3195](#3195))
([4a9dd53](4a9dd53))
* run independent tool calls in parallel with a TaskRunner
([#3243](#3243))
([8a8d877](8a8d877))
* run page-scoped tools on different pages in parallel
([#3252](#3252))
([f8f18ac](f8f18ac))
* **server:** add McpServer.applyConfig() to reload configuration
([#3051](#3051))
([f874aeb](f874aeb))
* **slim:** support filepath in slim mode screenshot
([#3296](#3296))
([d399c4a](d399c4a))
* Sort objects for class by self size in compare_heapsnapshots
([#3205](#3205))
([f8a18c6](f8a18c6))
* **tools:** support updating ToolHandler definitions in place
([#3050](#3050))
([8064138](8064138))
* **wait_for:** support opt-in visible text matching
([#3212](#3212))
([eef92da](eef92da))


### 🛠️ Fixes

* allow paths below a filesystem-root workspace root
([#3277](#3277))
([296ccef](296ccef))
* block evaluate_script while a dialog is open
([#3196](#3196))
([545bcbb](545bcbb))
* bound take_snapshot wait and clear the geolocation override
([#3265](#3265))
([6282f54](6282f54))
* **browser:** redact WebSocket headers from connection logs
([#2901](#2901))
([3a2f91e](3a2f91e))
* capture thrown transform errors as Zod validation issues
([#3242](#3242))
([0f95c21](0f95c21))
* clear handled dialog in McpPage.getDialog()
([#3241](#3241))
([2ab93a3](2ab93a3)),
closes
[#2958](#2958)
* **cli:** disallow --slim in CLI start options
([#2987](#2987))
([#3113](#3113))
([ae3f61c](ae3f61c))
* **cli:** do not forward tool args to start
([#3114](#3114))
([fef1598](fef1598))
* **cli:** serialize object daemon arguments
([#2797](#2797))
([f73a440](f73a440))
* close the tab opened by a failed new_page
([#2928](#2928))
([c174677](c174677))
* **config:** accept native JSON objects for wsHeaders in config files
([#3262](#3262))
([371e6c2](371e6c2))
* **console:** describe Map, Set, function, DOM node, symbol and cyclic
arguments
([#3199](#3199))
([968e4e5](968e4e5))
* **console:** include the reason of unhandled promise rejections
([#3148](#3148))
([f7cdf73](f7cdf73))
* defer focus emulation until a page is explicitly targeted
([#3230](#3230))
([35abe35](35abe35))
* disable Puppeteer signal handlers for launched browsers
([#3238](#3238))
([3afec73](3afec73)),
closes
[#3154](#3154)
* discover third-party developer tools before tool lookup
([#3208](#3208))
([2777fa0](2777fa0)),
closes
[#2973](#2973)
* discover third-party developer tools in page tools
([#3207](#3207))
([cf33529](cf33529)),
closes
[#2980](#2980)
* drop InlineTextBox nodes from text snapshots
([#3202](#3202))
([f8c775a](f8c775a))
* **emulate:** keep previous viewport when setViewport fails
([#3150](#3150))
([19d883c](19d883c))
* exclude source maps from the npm package
([#3295](#3295))
([d575da4](d575da4))
* keep surrogate pairs and emoji whole when truncating titles, names and
bodies
([#3215](#3215))
([058adcf](058adcf))
* let resourceTypes match CORS preflights and prefetches
([#3217](#3217))
([e342c6e](e342c6e))
* **lighthouse:** explain unsupported URL schemes in navigation mode
([#3144](#3144))
([c6ad84a](c6ad84a))
* **lighthouse:** fail the audit when Lighthouse reports a runtime error
([#3188](#3188))
([53ea78d](53ea78d))
* **lighthouse:** restore the emulated viewport after a navigation audit
([#3189](#3189))
([c777ece](c777ece))
* **network:** list only earlier hops in a request's redirect chain
([#3198](#3198))
([4675940](4675940))
* **network:** remove unsupported websocket from list_network_requests
filter
([#2961](#2961))
([#3282](#3282))
([8b119d9](8b119d9))
* **network:** reset page network emulation before applying URL
restrictions
([#3312](#3312))
([f25f3df](f25f3df))
* **pages:** reject devtools:// URLs in navigate_page and new_page
([#2935](#2935))
([#3128](#3128))
([c94c03d](c94c03d))
* **pages:** report the actual page size after resize_page
([#2998](#2998))
([#3132](#3132))
([f003056](f003056))
* **pages:** revert reporting the page size after resize_page
([#3308](#3308))
([186ea66](186ea66)),
closes
[#3306](#3306)
* **pagination:** return all items when pageSize is omitted and pageIdx
is 0
([#2996](#2996))
([#3134](#3134))
([3ad9115](3ad9115))
* **performance:** populate structuredContent for traceInsight in
performance_analyze_insight
([#2981](#2981))
([#3115](#3115))
([c2c6cc4](c2c6cc4))
* preserve unread page fallback across snapshots and deduplicate page
disposal
([#3161](#3161))
([8604365](8604365))
* print the no-roots temp-directory warning under default flags
([#2927](#2927))
([0dd8ed9](0dd8ed9))
* reject empty, extra, and unknown parts in geolocation and viewport
transforms
([#3097](#3097))
([#3120](#3120))
([3ea31fb](3ea31fb))
* resolve error causes on the session that reported the exception
([#3220](#3220))
([b2d37e8](b2d37e8))
* resolve service worker IDs for console messages
([#2881](#2881))
([3804a9a](3804a9a))
* return message when page exposes no 3p developer tools
([#3197](#3197))
([663f3c3](663f3c3)),
closes
[#3004](#3004)
* **script:** surface non-Error thrown and rejected values in
evaluate_script
([#2951](#2951))
([#3127](#3127))
([b1e6e68](b1e6e68))
* **security:** validate filesystem paths that parse as http/ws URLs
([#3186](#3186))
([91e112d](91e112d))
* **snapshot:** omit uid for accessibility nodes without backendNodeId
([#3187](#3187))
([9054351](9054351))
* stop active screencast on McpPage.dispose()
([#3160](#3160))
([558af81](558af81))
* stop get_network_request waiting forever for an unread no-store body
([#3216](#3216))
([c556be1](c556be1))
* surface nested error causes and autoConnect hint on connection failure
([#3111](#3111))
([d7f1e78](d7f1e78))
* treat 0.0.0.0 and [::] browser endpoints as local
([#3321](#3321))
([7d5f7de](7d5f7de))
* treat IPv4-mapped IPv6 loopback as localhost
([#3255](#3255))
([6cfb34c](6cfb34c))
* truncate data: URLs in accessibility snapshots
([#3292](#3292))
([11e7d47](11e7d47))
* validate type_text submitKey before typing
([#3185](#3185))
([c8615d7](c8615d7))
* **wait_for:** find text in dynamically attached iframes
([#3210](#3210))
([5f84888](5f84888))
* warn when screenshot dimensions exceed 16384px limit
([#2952](#2952))
([#3291](#3291))
([10455b7](10455b7))


### 📄 Documentation

* add a pre-submit checklist to AGENTS.md
([#3236](#3236))
([e96ee6c](e96ee6c))
* clarify URL pattern port and hostname matching
([#3287](#3287))
([4723335](4723335))
* **config:** note that hot-reloaded URL restrictions keep open targets
attached
([#3314](#3314))
([47db2b8](47db2b8))
* correct CLI availability of extension tools
([#2852](#2852))
([a7280ee](a7280ee))
* explain how to install skills, including the CLI skill
([#3274](#3274))
([46eb546](46eb546))
* remove experimental status from CLI and promote it in README
([#3273](#3273))
([0ced16b](0ced16b))
* require a rebase only when the branch has merge conflicts
([#3239](#3239))
([d1143ac](d1143ac))
* **skills:** clarify Lighthouse score scale and contrast workflow
([#3006](#3006))
([#3218](#3218))
([bf8c6d9](bf8c6d9))


### ⚡ Performance

* **cli:** load a small third-party bundle in the chrome-devtools CLI
([#3200](#3200))
([552e1f8](552e1f8))
* load the Lighthouse bundle on first use
([#3169](#3169))
([798f03a](798f03a))


### 🏗️ Refactor

* **config:** isolate scripts and unit tests from home config discovery
([#3256](#3256))
([65e6b97](65e6b97))
* **config:** lock slim and pageIdRouting options when viaCli is set
([#3257](#3257))
([384d9aa](384d9aa))
* **config:** pass ReadonlyServerConfig to BrowserManager and add
OptionGate
([#3267](#3267))
([98302f2](98302f2))
* **config:** simplify watchConfig and start watcher inside connect
([#3259](#3259))
([878c5f6](878c5f6))
* extract errorResult() and routedPageId() in ToolHandler
([#3251](#3251))
([9a6b03a](9a6b03a))
* import types and values from a module in one statement
([#3303](#3303))
([f4eaf1f](f4eaf1f))
* **lighthouse:** move Lighthouse runner methods to McpPage and add
bundle types
([#3283](#3283))
([e1823a2](e1823a2))
* list optional properties last in inline object types
([#3302](#3302))
([dc929c0](dc929c0))
* register all tools upfront across modes
([#3163](#3163))
([58f8d1f](58f8d1f))
* **response:** pass live ReadonlyServerConfig to McpResponse
([#3258](#3258))
([6042490](6042490))
* **tools:** add explicit factory types to tool definitions
([#3284](#3284))
([4d1d7b2](4d1d7b2))
* use #private names instead of the private modifier
([#3301](#3301))
([2e13311](2e13311))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Page-title truncation splits UTF-16 surrogate pairs, causing Python MCP response parsing to fail

2 participants