Repository navigation
fix: stop the chat composer rewriting typed characters (issue #1399) - #1429
Conversation
The composer rewrote characters as they were typed, and the rewritten string is what reached the model. Measured live: the composer DOM, the outbound POST /api/chat/completions body and the rendered transcript all agreed with each other and all three differed from what was typed, so a user asking about `git push --force` got an answer about `git push —force` and nothing said the text had been changed. The cause is @tiptap/extension-typography, registered bare in the composer's rich text extension list. Its rules are ProseMirror input rules, which rewrite the text buffer rather than its presentation, so the mutation survives into the request body. The issue proposed disabling six of those rules. Read against the package source that is incomplete: the extension registers twenty-one, and the six named leave `->`, `!=`, `<<`, `>>`, `+/-`, `2 * 3` and `^2` still rewriting. The extension contributes nothing but input rules, so dropping it removes every one of them and costs the composer nothing. Code inside a formed fence was always exempt, because input rules do not run in a code block. Code typed as prose was not, and on a coding surface that is the common case. The guard types a corpus through a real ProseMirror input-rule pipeline, with no DOM, and asserts the string the editor holds afterwards, which is the string the send path serializes. It first pins the harness against the corruption measured on the deployed box, so a harness that stopped reproducing the defect fails rather than reporting a false pass. Its rule set is derived from RichTextInput.svelte itself, so re-adding the extension turns the byte identity case red through simulated typing.
|
Warning Review limit reachedNext included review available in 35 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Review stream 1 of 3: CodeRabbit CLI
Finding, severity critical: Rebuttal, not accepted. The duplication does not exist. Checked against the file rather than against the description: The file has one Reading this as a likely artifact of the reviewer seeing an overlapping diff hunk rather than the resulting file. No change made. No other findings from this stream. |
|
Review stream 2, TypeScript and JavaScript domain review. Verdict: BLOCK, on one critical and two high findings, all posted inline. Merge readiness first, since it changes what to do next. The PR is a draft and Inline findings, in order of weight:
What I verified and found sound, so it is not re-litigated later:
One scope note for the record, not a defect. |
Addresses review on #1429. The guard never ran. `scripts/test-owui-hive-frontend.sh` copies `src/lib/hive` into a scratch tree and installed only vitest, coverage, svelte and the svelte vite plugin, so no `@tiptap` package could resolve there and the file failed to collect, taking the required `Repo policy lints (tenant + audit)` check red. This was the first test in that directory to need a third party runtime dependency. The three `@tiptap` packages are now pinned into that install from `vendor/open-webui/package-lock.json`, the same way svelte already is, so the lane runs the versions the image build resolves. `RichTextInput.svelte` also travels into the scratch tree now, since the guard reads it. The guard also claimed detection power it did not have. Keying on the literal identifier `Typography` meant an aliased import re-corrupted the composer and still passed. It now maps every imported identifier to the module it came from and keys on the module, so a rename is caught, and the check is an allowlist of reviewed modules rather than a denylist naming one, so a newly added rewriting extension fails until someone looks at it. Verified by mutation rather than asserted: with the extension re-added as `SmartText`, the typing case fails with `expected 'git push —force' to be 'git push --force'`. Also from review: the rule count is twenty two, not twenty one; the `matchedDocLength` re-validation is not in `@tiptap/core`'s `run` and its only failure direction was a false pass, so it is gone; the extension array slice anchored on the first of three `...(richText` occurrences and landed inside `StarterKit.configure`, so it now anchors on the array branch and asserts there is exactly one; the code block exemption was unreachable in the harness schema and is now a real `code_block` node with a test that types into it; and the `as never` cast is replaced by a narrowed call whose type names the three fields `textInputRule` actually reads. Adds the wire-level capture. The defect and the fix were run as a controlled pair on the same pinned backend image with the same script and payload, differing only in which frontend build was mounted. Before, the composer DOM and the outbound request body both carry U+2019, U+201C, U+201D, U+2014, U+2260 and U+2192 where straight quotes, a double hyphen, `!=` and `->` were typed. After, all three readings are byte identical. That also answers the same-day QA pass that could not reproduce the defect on the deployed box.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Visual proofIssue #1399, controlled pair on one pinned backend image, differing only in the mounted frontend build. Payload typed one keystroke at a time: PROBE1399 it's a "test" -- ok a != b f() -> int. Frame 1, origin/main frontend: the composer holds curly quotes, an em dash, a not-equal sign and a right arrow, and the intercepted POST /api/chat/completions body carries the same corruption. Frame 2, this branch: straight quotes, literal double hyphen, != and -> all survive, and the request body is byte identical to what was typed. Wire readings in docs/proof/composer-literal-input-2026-08-29/capture.md. |
Review streams, consolidatedThree streams ran. None was skipped. Stream 1, CodeRabbit CLI 0.7.5. Ran and completed, 1 finding, rebutted as a false positive with counter-evidence in a comment above. No change made. Stream 2, TypeScript domain reviewer. Seven inline findings, one critical, two high, four low. All seven addressed and resolved: six fixed, and one ( Stream 3, plain adversarial pass over my own diff. One finding, self-caught before stream 2 posted and already fixed in the same push: the first revision used Domain specialists not added. This diff touches no auth, money or input-parsing path in the security sense, so the mandatory security and database review did not trigger and Verification summary
|
## Summary This is the batched buglog follow-up for the pull requests merged to `main` on 2026-08-29. Its diff is `.wolf/buglog.jsonl` and nothing else. Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or failed build must be logged, but the line may never be appended on a fix branch. `merge=union` in `.gitattributes` resolves concurrent appends locally and is ignored by GitHub's server side merge, so two branches that both appended land in hard conflict there. An unmergeable pull request gets no `refs/pull/N/merge`, no `pull_request` run and therefore zero checks, and the required status gate then blocks the merge for a reason the page never states (issue #873). Each fix accordingly carried its entry in its own pull request body, and this pull request copies them onto `main` in one batch, which the protocol explicitly prefers over one pull request per entry. ## Scope examined Fifty nine pull requests merged to `main` on 2026-08-29. Forty eight of them carried at least one entry, for eighty two entries in total. Thirty two of those were already on `main` and are skipped, leaving fifty appended here from thirty four pull requests. The largest block of skips comes from #1342, the equivalent batch for the 2026-08-28 merges, which merged earlier the same day and already landed thirty six entries covering #1257, #1268, #1276, #1277, #1287, #1292, #1293, #1294, #1296, #1301, #1303, #1305, #1313, #1335 and #1337. ## What landed Fifty entries appended, one JSON object per line, append only. The 232 pre-existing lines are byte identical to `origin/main` (verified by hashing the first 232 lines of the result against the base file). Every line in the resulting file parses as JSON and carries `error_message`, `root_cause`, `fix` and `tags`. | Source | Entries | |---|---| | #1083 | 2 | | #1277 | 1 | | #1278 | 1 | | #1298 | 1 | | #1334 | 1 | | #1336 | 3 | | #1343 | 1 | | #1346 | 1 | | #1351 | 1 | | #1365 | 2 | | #1368 | 1 | | #1369 | 1 | | #1371 | 3 | | #1375 | 3 | | #1376 | 1 | | #1378 | 1 | | #1379 | 2 | | #1388 | 5 | | #1389 | 3 | | #1390 | 2 | | #1393 | 1 | | #1394 | 1 | | #1410 | 1 | | #1417 | 1 | | #1421 | 1 | | #1423 | 1 | | #1424 | 1 | | #1426 | 1 | | #1429 | 1 | | #1431 | 1 | | #1433 | 1 | | #1434 | 1 | | #1436 | 1 | | #1439 | 1 | Entries are copied verbatim from their source pull request bodies. Nothing was rewritten, no field was invented, and no field was added. No JSON needed repair: all eighty two extracted entries parsed on the first attempt and all four required fields were present on every one. ## Merged pull requests that carried no entry Eleven of the fifty nine. Recorded here because the gap is itself the useful signal. | Pull request | Title | Assessment | |---|---|---| | #1013 | chore(deps): bump the go-minor-patch group across 1 directory with 4 updates | Dependabot bump, no defect fixed, no entry expected | | #1015 | chore(deps): bump the go-minor-patch group across 1 directory with 6 updates | Dependabot bump, no entry expected | | #1016 | chore(deps): bump golang from 1.26-alpine to 1.27-alpine in /deploy/docker | Dependabot bump, no entry expected | | #1218 | chore(deps): bump postcss from 8.5.19 to 8.5.26 in /apps/desktop | Dependabot bump, no entry expected | | #1219 | chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in /apps/control-plane | Dependabot bump, no entry expected | | #1342 | chore: batch buglog entries for the 2026-08-28 merges | The previous batch pull request itself, correctly carries no entry of its own | | #1364 | chore: remove four dead skills and record the patterns that cost time | Protocol gap. The body records patterns that cost time, which is the shape of a buglog entry, but none was written as one | | #1383 | test: retire stale expected-failure markers, restore the ones that are true (#1381, #1382, #1324) | Protocol gap. Stale `it.fails` markers reading as red is a real defect that was fixed here and should have carried an entry | | #1384 | docs: correct D-047, hive-auto reverted to variable pricing (D-059) | Decision ledger correction, arguably a documentation defect, no entry written | | #1387 | chore(deps): bump next from 15.5.23 to 16.3.3 in /apps/agent-console | Dependabot bump, no entry expected | | #1398 | docs: rescue the 2026-08-25 parity captures and add the 2026-08-29 QA matrix evidence | Documentation and evidence rescue, no entry written | Six of the eleven are Dependabot bumps and one is the previous batch, so the genuine protocol gaps are #1364, #1383, #1384 and #1398. Of those, #1383 is the one worth a follow-up: it fixed a real defect class (a stale expected-failure marker reads as a red "Expect test to fail" and gets dismissed as pre-existing) and left no record. ## Entries skipped as already present Thirty two. Thirty of them matched an entry already on `main` on `error_message`, `id` or `fix`. Two more from #1278 are semantic duplicates that an exact match would have missed, and were skipped after reading the landed entries they duplicate: - #1278's `streaming content_block_start omits text field` entry is covered by the consolidated `bug-2026-08-28-anthropic-sdk-wire-conformance` entry landed from #1296, whose root cause names the same `omitempty` on `StreamContentBlock.Text`. - #1278's `GET /v1/models leaked an upstream provider name` entry is covered by `BUG-1284`, landed from #1300, which names the same `public.model_aliases.summary` publication path. #1278's third entry, on `top_k` forwarding producing a 400, is not covered anywhere on `main` and is appended here. #1342 recorded #1278 as fully "merged into #1296", which was accurate for two of its three entries. ## Note on entry quality One appended entry is thin: #1277's parity re-score record carries `error_message` of `n/a` and a root cause of "console had no privacy/data-policy surface at all". It is a parity gap record rather than a defect record. It is included exactly as written rather than embellished, per the protocol's preference for the author's own words. ## Test plan - [x] Branch cut fresh from `origin/main`, diff is `.wolf/buglog.jsonl` and nothing else - [x] First 232 lines byte identical to the base file (md5 match) - [x] All 282 resulting lines parse as JSON and carry `error_message`, `root_cause`, `fix` and `tags` - [x] No `.wolf/` telemetry (`anatomy.md`, `memory.md`, `token-ledger.json`, `hooks/_session.json`, `buglog.json`) in the commit - [ ] The six required checks report green via the inert path allowlist in `.github/workflows/ci.yml` --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>


Fixes #1399.
What was wrong
The chat composer rewrote characters as they were typed, and the rewritten string is what reached the model. It is not a display bug: the composer DOM, the outbound
POST /api/chat/completionsbody and the rendered transcript all agree with each other, and all three differ from what was typed.So a user who types
git push --forceasks the model aboutgit push —force, and nothing on screen says the text was changed.Reproduced as a controlled pair, because a QA pass could not reproduce it
An independent QA pass the same day typed straight quotes and a double hyphen into the composer on the deployed box, captured the outbound body, and found it byte identical. That is a real measurement and it contradicted the issue, so this was settled by experiment rather than by argument.
Both arms run the same pinned backend image the deploy uses,
ghcr.io/open-webui/open-webui:v0.10.2@sha256:9fcea9c6..., with a locally built frontend bind mounted over/app/build, which is exactly the arrangementdeploy/docker/Dockerfile.open-webuiproduces. Same image digest, same container environment, same script, same payload, same headless Chromium, same one-keystroke-at-a-time input (page.keyboard.type; pasting does not fire input rules and proves nothing). The only difference is which frontend build is mounted.Payload:
PROBE1399 it's a "test" -- ok a != b f() -> intbefore,
origin/mainfrontendafter, this branch
Note
!=becoming U+2260 and->becoming U+2192 in the before arm. Neither is in the issue, and neither would have been fixed by theconfigure()call the issue suggested.This does not explain what the QA pass saw, and it does not try to. The likely explanation is that
richText={$settings?.richTextInput ?? true}is a per-user setting and the extension is registered only inside therichTextbranch, so with rich text off there are no input rules to fire at all; an input method that does not dispatch key events would do it too. Both are consistent with every observation on record and neither changes the fix. Full detail indocs/proof/composer-literal-input-2026-08-29/capture.md.Root cause, and which layer holds it
vendor/open-webui/src/lib/components/common/RichTextInput.svelteimportedTypographyfrom@tiptap/extension-typographyand registered it bare, with noconfigure(), inside therichText ? [...] : []branch.Those rules are ProseMirror input rules. They rewrite the text buffer at the moment a character is typed, not its presentation, which is why the mutation survives serialization and leaves the browser.
This is the frontend layer, and it is the layer that is actually built. Per
.wolf/decisions.mdD-044,deploy/docker/Dockerfile.open-webuibuilds only the frontend fromvendor/open-webuiand replaces/app/build, so a change here is live. The other two layers were checked and ruled out rather than assumed:deploy/docker/owui-patches/rewrites the pinned upstream backend image. Nothing in that directory referencesRichTextInput,Typographyor the editor; the onlycomposermatches are backend Python for credits and RAG config. It would also be the wrong tool, since the mangling happens before the request is sent.deploy/docker/Caddyfile.owuiis a reverse proxy and never sees the character stream before send.No exact-literal bundle patch matches text this change moves, so nothing under
owui-patches/breaks.Why remove the extension rather than configure the rules off
The issue proposed
Typography.configure({...})with six rules disabled. Read against@tiptap/extension-typography@3.20.2, the version the lockfile pins, that is incomplete: the extension registers twenty two input rules, and the six named leave sixteen live, including these.rightArrow->→notEqual!=≠laquo/raquo<<>>«»leftArrow<-←multiplication2 * 32×3superscriptTwo^2²plusMinus+/-±copyrightand friends(c)(r)(tm)©®™oneHalfand friends1/21/4½¼What a user loses. Everything the extension does, which is automatic typographic prettifying while typing prose: curly quotes, a real em dash from
--, a single ellipsis glyph,©,®,™,±,≠, arrows and vulgar fractions. Nothing else; the extension defines onlyname,addOptionsandaddInputRules, so there is no command, mark, node or shortcut to lose with it.That is a real cost and it is worth naming. The judgement is that on a coding assistant, where people type shell flags, comparisons and string literals into the composer constantly, silent prettifying is a liability rather than a feature, and a user who wants an em dash can still type one, which is covered by a test. Anyone who disagrees can reinstate it with
configure()and the guard will tell them exactly which rules they turned back on.Scope
RichTextInputis shared by the chat composer, the channel composer,InputModalandNoteEditor, and all of them take therichTextdefault oftrue. So the Notes editor also loses smart typography, not just chat. That is deliberate rather than incidental: silently rewriting literal input is the defect class, not a chat-only symptom, and fixing it once where all four callers route through is a smaller diff than four guards. Calling it out because it is a real, if minor, behaviour change on a surface the issue does not mention.Code inside a formed fence was always exempt, because input rules do not run in a code block. That is unchanged, this change only removes rules, and there is now a test that types into a real
code_blocknode with the full rule set active to pin it.One thing this deliberately does not do: the repository bans dash punctuation in prose that agents write. That rule constrains generated prose. It is not implemented here as a filter over user input, because the whole point of the issue is that the user's literal characters survive, an em dash included, when the user is the one who typed it. There is a test for exactly that.
Test
vendor/open-webui/src/lib/hive/composer-literal-input.test.ts, 5 tests.The repo has no DOM harness for the chat frontend, so rather than assert on the DOM (which would be the wrong assertion anyway, since the issue is about what is sent) the test types a corpus through a real ProseMirror input-rule pipeline and asserts the string the editor holds afterwards, which is the string the send path serializes. ProseMirror's model and state are pure JavaScript; only the view needs a DOM. The matching and range arithmetic mirror
runin@tiptap/core@3.20.2, and the replacement is the library's owntextInputRulehandler rather than a reimplementation.What keeps it honest:
import SmartText from '@tiptap/extension-typography'makes the typing case fail withexpected 'git push —force' to be 'git push --force'. An earlier revision keyed on the literal nameTypographyand was demonstrably blind to that rename.Where it runs, verified rather than claimed. The first revision of this pull request asserted it ran in CI and that was false:
scripts/test-owui-hive-frontend.shcopiessrc/lib/hiveinto a scratch tree and installed only vitest, coverage, svelte and the svelte vite plugin, so no@tiptappackage could resolve and the requiredRepo policy lints (tenant + audit)check went red. This was the first test in that directory to need a third party runtime dependency. Fixed by pinning the three@tiptappackages into that install fromvendor/open-webui/package-lock.json, the same mechanism svelte already uses, and by copyingRichTextInput.svelteinto the scratch tree since the guard reads it. Proven by running that lane's own script locally:It also runs at image build time via
RUN npm run test:frontend -- --runindeploy/docker/Dockerfile.open-webui, where the full suite is 251 passed andnpm run buildsucceeds.No new application dependency.
vendor/open-webui/package.jsonandpackage-lock.jsonare unchanged.Buglog entry
{"id":"1399-composer-smart-typography","date":"2026-08-29","title":"Chat composer rewrote typed characters and the rewritten text reached the model","error_message":"Typing `git push --force` into the chat composer sent `git push —force`; straight quotes became curly, `--` an em dash, `...` an ellipsis glyph, and `->`, `!=`, `<<`, `>>`, `2 * 3`, `^2` were also rewritten","root_cause":"vendor/open-webui/src/lib/components/common/RichTextInput.svelte registered @tiptap/extension-typography bare in the richText extension array. Its twenty-two entries are ProseMirror input rules, which rewrite the text buffer as a character is typed rather than its presentation, so the mutated string is what the editor serializes and what leaves the browser. Code inside a formed code block was exempt because input rules do not run there, which made the defect look narrower than it was and sent an earlier hunt to the marked renderer and to CSS ligatures, neither of which is the layer. A same-day QA pass could not reproduce it on the deployed box, most likely because richText is a per-user setting and the extension is registered only inside that branch.","fix":"Removed the import and the registration. The extension contributes nothing but input rules, so dropping it removes all twenty-two; the suggested configure() with six rules disabled would have left sixteen live, including -> and !=. Guarded by vendor/open-webui/src/lib/hive/composer-literal-input.test.ts, which types a corpus through a real ProseMirror input-rule pipeline with no DOM, asserts the serialized string, pins the harness against the measured corruption so it can go red, and keys on the imported module rather than the identifier so an aliased re-add is caught. Proven with a before/after wire capture on one pinned backend image differing only in the mounted frontend build.","tags":["chat","open-webui","composer","tiptap","prosemirror","frontend","issue-1399"]}