diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index e25d49dbfe7..965cc46e988 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 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 9716fc27937..2be28e2b812 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)๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +๊ธด ๊ฒ€์ƒ‰์–ด์™€ ๋‹ต๋ณ€์€ UTF-16 ์„œ๋กœ๊ฒŒ์ดํŠธ ์Œ์„ ๋‚˜๋ˆ„์ง€ ์•Š๋Š” ์œ„์น˜์—์„œ ์ค„์ด๋ฏ€๋กœ, ๊ฒฐ๊ณผ ๊ธธ์ด ์ œํ•œ ๋•Œ๋ฌธ์— ๐Ÿ˜€ ๊ฐ™์€ ํ•œ ๊ธ€์ž๊ฐ€ ๋ฐ˜์œผ๋กœ ์ž˜๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ ์ œ๋กœ ํญ ๊ฒฐํ•ฉ์ž(ZWJ)๋กœ ์ด์–ด์ง„ ์ด๋ชจ์ง€์ฒ˜๋Ÿผ ์—ฌ๋Ÿฌ ๊ธ€์ž๋กœ ์ด๋ฃจ์–ด์ง„ ์กฐํ•ฉ์€ ์ค‘๊ฐ„์—์„œ ์ž˜๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. 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..1882aea3b34 100644 --- a/src/web-search/format-result.ts +++ b/src/web-search/format-result.ts @@ -10,8 +10,9 @@ 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 : `${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 +26,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..ba5ccc1efea --- /dev/null +++ b/tests/web-search/fix-search-unicode-clamp.test.ts @@ -0,0 +1,27 @@ +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("๐Ÿ˜€"); + }); + 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) + "๐Ÿ˜€โ€ฆ"); + }); +});