From 5e38311c76a647e907901c2abaae6baa0870a82a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Mon, 26 Oct 2026 15:10:19 +0900 Subject: [PATCH 1/2] fix(search): preserve Unicode when truncating single results --- docs-site/src/content/docs/guides/sidecars.md | 2 ++ .../src/content/docs/ko/guides/sidecars.md | 2 ++ scripts/test-layout/layout-adapters.json | 1 + src/web-search/format-result.ts | 4 ++-- structure/data-planes/search.md | 2 ++ tests/fixtures/test-layout-expected.json | 4 +++- .../fix-search-unicode-clamp.test.ts | 21 +++++++++++++++++++ 7 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 tests/web-search/fix-search-unicode-clamp.test.ts diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index e25d49dbfe7..ec75fd0b5be 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -283,3 +283,5 @@ Claude Code OAuth fingerprint precedent, but should be soak-tested with the intended account and workload. See the [Configuration reference](/reference/configuration/server/#sidecars) for every field. + +Long search queries and answers are shortened at complete Unicode character boundaries, so emoji are not split by the result limits. diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index 9716fc27937..5706fb37456 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -158,3 +158,5 @@ OpenCodex가 `web_search` 가로채기를 멈추고 Codex 통합이 `~/.codex/co 파일을 직접 고치고 싶다면 이전처럼 `config.json`에서 `enabled`를 `false`로 두면 됩니다. Anthropic OAuth 검색과 이미지 설명은 기존 Claude Code OAuth fingerprint 선례를 따르지만, 실제 계정과 작업량으로 충분히 soak test하는 편이 좋습니다. 전체 필드는 [설정 레퍼런스](/ko/reference/configuration/server/#sidecars)를 참고하세요. + +긴 검색어와 답변은 유니코드 문자가 끊어지지 않는 위치에서 줄이므로 결과 길이 제한으로 이모지가 깨지지 않습니다. diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 08f0ab47160..9bed29ba154 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,4 +1,5 @@ { + "fix-search-unicode-clamp.test.ts": "web-search", "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", diff --git a/src/web-search/format-result.ts b/src/web-search/format-result.ts index be8295c9b1c..5808b8a0c2c 100644 --- a/src/web-search/format-result.ts +++ b/src/web-search/format-result.ts @@ -11,7 +11,7 @@ const MAX_TOTAL_CHARS = 8000; const MAX_BATCH_ERROR_CHARS = 300; function clamp(s: string, max: number): string { - return s.length <= max ? s : `${s.slice(0, max)}\n…[truncated]`; + return s.length <= max ? s : `${safePrefix(s, max)}\n…[truncated]`; } /** A prefix of at most `max` UTF-16 units that never ends on half of a surrogate pair. */ @@ -25,7 +25,7 @@ function safePrefix(s: string, max: number): string { /** Clamp and sanitize a query string for display in the tool result boundary. */ function safeQuery(q: string): string { - const clamped = q.length <= 200 ? q : `${q.slice(0, 200)}…`; + const clamped = q.length <= 200 ? q : `${safePrefix(q, 200)}…`; // Strip angle brackets so the query can't close/open XML-ish boundary tags. return clamped.replace(/[<>]/g, ""); } diff --git a/structure/data-planes/search.md b/structure/data-planes/search.md index 1a5d79d7501..4b1e27aeb90 100644 --- a/structure/data-planes/search.md +++ b/structure/data-planes/search.md @@ -57,3 +57,5 @@ routing. Do not infer an exact account from caller-supplied account headers, pro selection, connection identity, or other ambient state; concurrent threads could cross-route credentials. Extending exact routing to those endpoints requires an opaque client correlation that can be bound server-side to a previously validated selector. + +Single-result query and answer truncation in `src/web-search/format-result.ts` uses the same surrogate-safe prefix as batched results. The existing UTF-16 limits and truncation markers remain unchanged. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d1f3dad7a49..ba513f26261 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,6 @@ -{ "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", +{ + "fix-search-unicode-clamp.test.ts": "web-search", + "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "claude-intercept-acl-barrier.test.ts": "claude-integration", "codex-catalog-heal-reanchor.test.ts": "codex-integration", "tool-identity-hash.test.ts": "adapters", diff --git a/tests/web-search/fix-search-unicode-clamp.test.ts b/tests/web-search/fix-search-unicode-clamp.test.ts new file mode 100644 index 00000000000..c0fee50ed01 --- /dev/null +++ b/tests/web-search/fix-search-unicode-clamp.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { formatWebSearchResult } from "../../src/web-search/format-result"; + +describe("single search result Unicode boundaries", () => { + test.each([false, true])("never splits query or answer surrogate pairs (structured=%s)", structured => { + const query = "q".repeat(199) + "😀tail"; + const text = "a".repeat(3999) + "😀tail"; + const result = formatWebSearchResult(query, { text, sources: [] }, structured); + const shown = structured ? JSON.parse(result.split("\n")[1]!) : { query: result, answer: result }; + expect(shown.query).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/u); + expect(shown.answer).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/u); + expect(shown.answer).toContain("…[truncated]"); + }); + test("preserves complete pairs exactly at the limit", () => { + const text = "a".repeat(3998) + "😀"; + const result = formatWebSearchResult("q".repeat(198) + "😀", { text, sources: [] }, true); + const data = JSON.parse(result.split("\n")[1]!); + expect(data.answer).toBe(text); + expect(data.query).toEndWith("😀"); + }); +}); From 157d1de79b7ce117adca07a4c0643c4668817309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Tue, 27 Oct 2026 15:18:14 +0900 Subject: [PATCH 2/2] docs(search): limit the truncation guarantee to surrogate pairs --- docs-site/src/content/docs/guides/sidecars.md | 2 +- docs-site/src/content/docs/ko/guides/sidecars.md | 2 +- src/web-search/format-result.ts | 1 + tests/web-search/fix-search-unicode-clamp.test.ts | 6 ++++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index ec75fd0b5be..965cc46e988 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -284,4 +284,4 @@ intended account and workload. See the [Configuration reference](/reference/configuration/server/#sidecars) for every field. -Long search queries and answers are shortened at complete Unicode character boundaries, so emoji are not split by the result limits. +Long search queries and answers are shortened without splitting a UTF-16 surrogate pair, so a single character such as 😀 is never cut in half by the result limits. A sequence of several characters, such as emoji joined with a zero-width joiner, can still be cut between its parts. diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index 5706fb37456..2be28e2b812 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -159,4 +159,4 @@ OpenCodex가 `web_search` 가로채기를 멈추고 Codex 통합이 `~/.codex/co fingerprint 선례를 따르지만, 실제 계정과 작업량으로 충분히 soak test하는 편이 좋습니다. 전체 필드는 [설정 레퍼런스](/ko/reference/configuration/server/#sidecars)를 참고하세요. -긴 검색어와 답변은 유니코드 문자가 끊어지지 않는 위치에서 줄이므로 결과 길이 제한으로 이모지가 깨지지 않습니다. +긴 검색어와 답변은 UTF-16 서로게이트 쌍을 나누지 않는 위치에서 줄이므로, 결과 길이 제한 때문에 😀 같은 한 글자가 반으로 잘리지 않습니다. 다만 제로 폭 결합자(ZWJ)로 이어진 이모지처럼 여러 글자로 이루어진 조합은 중간에서 잘릴 수 있습니다. diff --git a/src/web-search/format-result.ts b/src/web-search/format-result.ts index 5808b8a0c2c..1882aea3b34 100644 --- a/src/web-search/format-result.ts +++ b/src/web-search/format-result.ts @@ -10,6 +10,7 @@ const MAX_TOTAL_CHARS = 8000; /** Bound for a batched error status so every query's status line stays small. */ const MAX_BATCH_ERROR_CHARS = 300; +/** Truncate to at most `max` UTF-16 units without splitting a surrogate pair, then mark the cut. */ function clamp(s: string, max: number): string { return s.length <= max ? s : `${safePrefix(s, max)}\n…[truncated]`; } diff --git a/tests/web-search/fix-search-unicode-clamp.test.ts b/tests/web-search/fix-search-unicode-clamp.test.ts index c0fee50ed01..ba5ccc1efea 100644 --- a/tests/web-search/fix-search-unicode-clamp.test.ts +++ b/tests/web-search/fix-search-unicode-clamp.test.ts @@ -18,4 +18,10 @@ describe("single search result Unicode boundaries", () => { expect(data.answer).toBe(text); expect(data.query).toEndWith("😀"); }); + test("keeps a pair that ends exactly at the limit when truncation occurs", () => { + const result = formatWebSearchResult("q".repeat(198) + "😀tail", { text: "a".repeat(3998) + "😀tail", sources: [] }, true); + const data = JSON.parse(result.split("\n")[1]!); + expect(data.answer).toBe("a".repeat(3998) + "😀\n…[truncated]"); + expect(data.query).toBe("q".repeat(198) + "😀…"); + }); });