Skip to content

Robustness: make the provider-invocation output cap UTF-8-byte-accurate and surrogate-safe (capOutput) #848

Description

@qwen-code-dev-bot

sourceType

source:self-discovery — undirected maintainer review pass. Same defect class as the output-cap hardening series (#830/#832/#834/#836/#838), in a site that series never touched.

sourceLinkOrEvidence

Concrete evidence on main @ 28c180d. The provider-response output cap in src/provider-invocation.ts:241-244 treats UTF-16 code units as bytes and slices by code units:

function capOutput(text: string, maxBytes: number): { text: string; capped: boolean } {
  if (text.length <= maxBytes) return { text, capped: false };
  return { text: text.slice(0, maxBytes), capped: true };
}

It is applied to the raw provider response content at src/provider-invocation.ts:163 (const text = choice?.message?.content ?? ""; const capped = capOutput(text, opts.maxOutputBytes);). Provider responses routinely contain multi-byte text (CJK, emoji), so both defects are reachable:

  1. Byte under-counttext.length counts UTF-16 code units, not UTF-8 bytes. For multi-byte text the real byte count can exceed maxBytes by up to ~3×, so the advertised byte cap does not bound bytes.
  2. Surrogate splittext.slice(0, maxBytes) cuts at a code-unit offset and can orphan half of a surrogate pair, leaving a broken trailing character in the capped report.

Minimal reproduction (mirrors capOutput exactly):

capOutput("a".repeat(999) + "🚀", 1000) → capped=true, result ends in a lone high surrogate
capOutput("你".repeat(1000), 1500)      → capped=false, but the text is 3000 UTF-8 bytes (> the 1500-byte cap)

Observed output: surrogate-split: capped= true last code unit is lone high surrogate= true len= 1000 and byte-undercount: capped= false … actual bytes= 3000. The codebase already accounts bytes correctly elsewhere (Buffer.byteLength in src/headless-protocol.ts:143, src/desktop/service.ts:1184, src/tui-shell.ts:3216) and exposes a surrogate-safe byte-budgeted cut (safeByteCutEnd in src/text-cut.ts), so the fix is idiomatic.

problemStatement

The provider-invocation output cap is named, documented, and reported as a byte cap (maxOutputBytes, "…-byte output cap"), but capOutput compares String.length (UTF-16 code units) to the byte budget and slices by code units. For a multi-byte provider response the byte budget is silently exceeded (up to ~3×), and when the cap boundary lands inside a surrogate pair the capped text ends in an unpaired surrogate. The capped response is rendered into the invocation report, so the user can be shown a broken trailing character, and the cap does not provide the memory bound it advertises.

userValue

Capped provider responses keep non-ASCII text intact (no broken trailing glyph) and the byte cap actually bounds UTF-8 bytes, consistent with the hook/tool/MCP/shell output caps already hardened in #830/#832/#834/#836/#838. Removes a user-visible corruption and restores the advertised memory bound on the provider-invocation path.

scope

  • Make capOutput (src/provider-invocation.ts) UTF-8-byte-accurate and surrogate-safe: decide "fits" via Buffer.byteLength(text, "utf8") <= maxBytes and cut via safeByteCutEnd from src/text-cut.ts (which honors the byte budget and never orphans a surrogate).
  • Preserve the existing { text, capped } contract and the output-capped outcome/reason wording; in-range ASCII output must be unchanged.
  • Add regression tests covering a surrogate straddling the cap, a multi-byte text whose bytes exceed the cap while its code units do not, and an in-range ASCII case.

nonGoals

acceptanceCriteria

  • A provider response whose cap boundary falls inside a surrogate pair is capped without leaving an unpaired surrogate (no [\uD800-\uDBFF] at the end of the capped text).
  • A multi-byte response whose UTF-8 byte count exceeds maxBytes is reported capped=true even when its UTF-16 length is <= maxBytes, and the capped text's Buffer.byteLength(…, "utf8") is <= maxBytes.
  • In-range ASCII output is byte-for-byte unchanged (capped=false, text identical).
  • Regression tests cover the surrogate-straddle, byte-overrun, and in-range ASCII cases; existing tests pass.

testPlan

Behavior-sensitive unit tests in tests/unit/provider-invocation.test.ts alongside the existing ASCII "caps oversized output" case: drive the runner (or the capped path) with a multi-byte response whose surrogate pair straddles the cap and assert no unpaired surrogate; with a CJK response whose bytes exceed the cap and assert capped=true and byteLength <= maxBytes; and assert the ASCII in-range case is unchanged. Deterministic string/byte assertions.

dogfoodPlan

Run the capped path with a multi-byte (emoji + CJK) provider response over the byte cap and confirm the report shows intact characters (no U+FFFD/lone-surrogate glyph) and the cap is honored in bytes; run the provider-invocation unit suite to confirm the regression cases hold.

riskAndSecurityNotes

Low risk: pure string-capping helper; the only behavior change is that multi-byte responses are capped at a true byte boundary without splitting characters. In-range ASCII output is unchanged. No state mutation, no privilege change. The credential is never part of the capped body (already redacted downstream when the report is built).

duplicateSearchEvidence

parentChildRelationship

Standalone source:self-discovery robustness Issue (not a child of any roadmap parent). Complements the closed output-cap hardening series by covering the provider-invocation cap it did not.

dependencyOrder

No blocking dependencies; executable immediately (capOutput and safeByteCutEnd both already exist on main @ 28c180d). Single vertical slice: make one function byte-accurate and surrogate-safe, plus focused regression tests.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent-readyTrusted issue ready for autonomous implementationbugSomething isn't workingpriority:p2Normal priority: schedule in regular ordersource:self-discoveryNormalized execution work from reproducible product dogfood

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions