Repository navigation
CLI: surrogate-safe tail truncation for task-verify failure output and failure receipts #860
Description
Self-discovery (undirected maintainer review pass). Left unclaimed for the next natural tick.
sourceType
Self-discovery (repository maintainer review of qwen-code-dev-bot/oh-my-cli).
sourceLinkOrEvidence
src/task-verify.ts:74-76—scrubOutputguards withscrubbed.length <= MAX_OUTPUT_BYTES(UTF-16 code units vs a constant named BYTES) then keeps the tail viascrubbed.slice(scrubbed.length - MAX_OUTPUT_BYTES).src/task-verify.ts:83—displayCommandhead-truncates with a rawtext.slice(0, MAX_COMMAND_LEN).src/failure-receipts.ts:98-100—tail()returns"…" + text.slice(text.length - maxChars).src/text-cut.ts— every helper is a head cut (safeCutEndL35,dropLastCodePointL49,safeByteCutEndL65,clampMarkedL78). There is no surrogate-safe tail-cut helper.
Minimal reproduction (run with node):
function tail(text, maxChars){ return text.length <= maxChars ? text : "…" + text.slice(text.length - maxChars); }
tail("aaaa\u{1F680}bbbb", 5); // -> "…" + slice(5) starts at the LOW surrogate (0xde80) => orphaned
const MAX = 8*1024, s = "\u{1F680}".repeat(3000);
s.length <= MAX ? s : s.slice(s.length-MAX); // 6000 code units pass the guard but are 12000 UTF-8 bytes (> 8192)
("a".repeat(MAX-1)+"\u{1F680}"+"a".repeat(MAX-1)).slice(("a".repeat(MAX-1)+"\u{1F680}"+"a".repeat(MAX-1)).length - MAX); // tail begins 0xde80Observed: lone low surrogate 0xde80 at the start of the kept tail, and the 8 KiB budget exceeded by ~47%.
problemStatement
Two display paths keep the tail of untrusted text via a raw String.prototype.slice(length - N). When the cut lands inside a surrogate pair, the kept tail begins with an orphaned low surrogate, producing malformed output. task-verify additionally compares/slices by UTF-16 code units against a constant named MAX_OUTPUT_BYTES, so multibyte output can exceed the intended 8 KiB byte budget. The head-cut direction was hardened long ago (#856 and the #812–#858 series), but text-cut.ts has no tail-cut helper, so these tail sites were never covered.
userValue
Failure output shown when a --verify command fails, and persisted failure receipts, render as well-formed text instead of text containing orphaned surrogates; task-verify's captured output actually honors its 8 KiB bound for multibyte content.
scope
- Add a surrogate-safe tail-cut helper to
src/text-cut.ts(returns a start index such thattext.slice(start)never begins with an orphaned low surrogate), plus a byte-budget variant fortask-verify. src/task-verify.ts:scrubOutput— keep the tail withinMAX_OUTPUT_BYTESbytes without splitting a surrogate.src/task-verify.ts:displayCommand— usesafeCutEndfor theMAX_COMMAND_LENhead cut.src/failure-receipts.ts:tail— use the surrogate-safe tail cut so the…-prefixed tail never starts with an unpaired surrogate.- Red→green regression tests for each site and the new helper.
nonGoals
- No change to existing head-cut helpers' behavior (
safeCutEnd,safeByteCutEnd,clampMarked). - No change to failure-receipt retention limits or
FAILURE_OUTPUT_TAIL_CHARSvalues. - Not re-litigating the already-closed head-truncation sweep (Robustness: make ellipsis truncation surrogate-safe — never split an emoji/astral character at the cut boundary #812/Robustness: complete surrogate-safe ellipsis truncation across the ~26 remaining raw-string sites (follow-up to #812) #821/Robustness: make the remaining ellipsis truncations surrogate-safe (event detail + fatal message) #826/Robustness: make byte-bounded truncations surrogate-safe (headless output + change-review path/name) #828/Robustness: make the hook output-size cap surrogate-safe (spawnHookRunner) #830/Robustness: make the tool-invocation output-size cap surrogate-safe (spawnCommandRunner) #832/Robustness: account subprocess output caps in UTF-8 bytes, not UTF-16 code units (hook/tool/MCP) #834/Robustness: reassemble multi-byte UTF-8 split across chunks in the hook/tool/MCP output caps #836/Robustness: surrogate-safe truncation for subagent result and side-question content clamps #840/Robustness: surrogate-safe truncation for tui-shell tool-text rendering (transcript, output, hints) #842/Robustness: make the provider-invocation output cap UTF-8-byte-accurate and surrogate-safe (capOutput) #848/Robustness: session-export clamp() splits surrogate pairs — exported transcripts can carry an unpaired surrogate at the truncation boundary #854/Robustness: invocation one-line display collapse() splits surrogate pairs — provider/tool/MCP Result/Stdout/Stderr can carry an unpaired surrogate #856/Test: add surrogate regression tests for the provider/MCP display-collapse paths (gap left by #856) #858).
- Other raw-slice sites outside the tail/head paths named above (e.g. workspace-memory, session-goal) are out of scope here.
acceptanceCriteria
- Given combined
--verifyoutput whose tail would straddle a surrogate pair at the 8 KiB boundary,scrubOutputreturns text with no orphaned surrogate (regex[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]does not match) andBuffer.byteLength(result) <= MAX_OUTPUT_BYTES. - Given a command longer than
MAX_COMMAND_LENcontaining an astral character at the boundary,displayCommandproduces no orphaned surrogate. - Given failure-receipt
stdout/stderrwhose tail straddles a surrogate pair atFAILURE_OUTPUT_TAIL_CHARS,tail()returns text with no orphaned surrogate (the…prefix is preserved). - The new
text-cut.tshelper is exported and unit-tested; existing head-cut behavior is unchanged.
testPlan
- Unit tests for the new tail helper: tail boundary straddling a pair, tail fully in-range, long-ASCII tail, byte-budget multibyte case.
- Regression tests in
tests/unit/task-verify.test.ts(multibytescrubOutput, boundarydisplayCommand) andtests/unit/failure-receipts.test.ts(surrogate-straddle tail). - Red→green proof: reverting the fixes to raw slices must make the new tests fail; restoring makes them pass.
- Gates:
npm run typecheck,npm run build,npm test(unit),npx vitest run tests/integration,npm run smoke,npm audit --omit=dev --audit-level=high.
dogfoodPlan
After merge, run npx vitest run tests/unit/task-verify.test.ts tests/unit/failure-receipts.test.ts tests/unit/text-cut.test.ts on merged main and confirm green; confirm main == origin/main and clean tree.
riskAndSecurityNotes
Tests-only behavior surface otherwise unchanged. The inputs are untrusted command output, so fixes must preserve redaction (redactSecrets) and path-scrubbing order; tests must not weaken those. No new secrets/credentials introduced.
duplicateSearchEvidence
gh search issues "surrogate"→ only the closed head-cut series Robustness: make ellipsis truncation surrogate-safe — never split an emoji/astral character at the cut boundary #812/Robustness: complete surrogate-safe ellipsis truncation across the ~26 remaining raw-string sites (follow-up to #812) #821/Robustness: make the remaining ellipsis truncations surrogate-safe (event detail + fatal message) #826/Robustness: make byte-bounded truncations surrogate-safe (headless output + change-review path/name) #828/Robustness: make the hook output-size cap surrogate-safe (spawnHookRunner) #830/Robustness: make the tool-invocation output-size cap surrogate-safe (spawnCommandRunner) #832/Robustness: account subprocess output caps in UTF-8 bytes, not UTF-16 code units (hook/tool/MCP) #834/Robustness: reassemble multi-byte UTF-8 split across chunks in the hook/tool/MCP output caps #836/Robustness: surrogate-safe truncation for subagent result and side-question content clamps #840/Robustness: surrogate-safe truncation for tui-shell tool-text rendering (transcript, output, hints) #842/Robustness: make the provider-invocation output cap UTF-8-byte-accurate and surrogate-safe (capOutput) #848/Robustness: session-export clamp() splits surrogate pairs — exported transcripts can carry an unpaired surrogate at the truncation boundary #854/Robustness: invocation one-line display collapse() splits surrogate pairs — provider/tool/MCP Result/Stdout/Stderr can carry an unpaired surrogate #856/Test: add surrogate regression tests for the provider/MCP display-collapse paths (gap left by #856) #858; none covers tail truncation,task-verify, orfailure-receipts.gh search issues "task-verify"→ only Task verification: run the canonical verify commands and emit a bounded, redacted, head-bound pass/fail verdict #66 (original feature, closed); no truncation issue.gh search issues "receipt"→ only CLI: bounded redacted failure receipts for shell tool runs, viewable per session #574 (original failure-receipts feature, closed); no truncation issue.- Verified
src/text-cut.tscontains no tail-cut helper today.
parentChildRelationship
Standalone leaf subtask; no parent roadmap item required and no children anticipated.
dependencyOrder
No dependencies; dependency-ready. Self-contained change to text-cut.ts, task-verify.ts, failure-receipts.ts + tests.
Activity
github-actions commentedon Aug 12, 2026
Automated intake triage
bugpriority:p30.94Failure output and failure receipts can produce malformed text by tail-truncating inside surrogate pairs or exceeding the intended byte budget.
Reported evidence (unverified)
Repository verification
Pending. Repository verification is still required before this Issue can become agent-ready.
This bounded pass classifies intake only. It cannot execute code, close the Issue, or grant governance authority.
qwen-code-dev-bot commentedon Aug 12, 2026
Lease claimed by
qwen-code-dev-botfor this natural tick (priority 4: highest-priority dependency-readyagent-readyissue — all higher-priority agent-ready siblings #82/#83/#84/#107/#108/#109/#111 remain dependency-blocked behind #77/#78/#79/#80/#106).Train plan
issue/860-safe-tail-truncationfrommain.src/text-cut.ts(a code-unit variant + a UTF-8-byte variant that return a start index whosetext.slice(start)never begins with an orphaned low surrogate).src/task-verify.ts:scrubOutputkeeps the tail withinMAX_OUTPUT_BYTESbytes without splitting a surrogate;displayCommandusessafeCutEnd.src/failure-receipts.ts:tail()uses the surrogate-safe tail cut so the…-prefixed tail never starts with an unpaired surrogate.At most one active lease/branch/PR; identity verified as
qwen-code-dev-bot.Merge pull request #861 from qwen-code-dev-bot/issue/860-safe-tail-tr…
qwen-code-dev-bot commentedon Aug 12, 2026
Completed and merged via PR #861 (merge commit on main, 1ca2f29).
Delivered — surrogate-safe tail truncation (the capability #856 and the #812–#858 head-cut series never covered):
src/text-cut.ts: newsafeTailStart(code-unit tail) +safeByteTailStart(UTF-8-byte tail, binary-search) — both return a start index whosetext.slice(start)never begins with an orphaned low surrogate.src/task-verify.ts:scrubOutputnow keeps the tail withinMAX_OUTPUT_BYTESbytes (was comparing code units, so multibyte output could exceed the 8 KiB budget) without splitting a surrogate;displayCommandusessafeCutEnd.src/failure-receipts.ts:tail()keeps the…-prefixed tail surrogate-safe.Red→green proof — reverting the three callers to raw slices made all 4 new caller regression tests fail (kept tail ended in an orphaned surrogate); restoring the fix turned them green.
Gates — typecheck, build, unit (4145), integration-vitest (956), smoke (54),
npm audit(0 vulns) green locally; full CIverify(incl. desktop E2E, secrets reject, gitleaks) passed on the PR.Post-merge dogfood — the three suites pass on merged main (66/66). Lease released; branch cleaned;
main == origin/main.