chat: the composer counts the two separators Go escapes
Codex S4-7 (P2), and the last of W4-1. U+2028 and U+2029 are the one place the two languages disagree about what "the JSON escaping of this string" means: Go's encoder escapes them unconditionally — `SetEscapeHTML(false)` included — and JavaScript's `JSON.stringify` leaves them literal. Three bytes on the wire as themselves, six once Go has written them. So 2045 a's and one U+2028 counted 2048 in the composer and 2051 at the server: the panel accepted the draft, the server refused it 413, and "Could not send. Try again." was untrue advice — the retry could never work. A paste of anything with a line separator in it (some editors, some PDFs, plenty of the web) lands exactly there. `chatBytes` escapes them the way Go does. The pattern is written as escapes rather than as the characters themselves, because both are line terminators in JavaScript source and a literal one inside a regular expression is a syntax error — which is most of why this gap existed. Client test seen failing first (6 !== 3, both characters, both sides of the boundary), and five matching cases on the server, sent as themselves the way a paste arrives. The server was always right here; what the Go cases add is the agreement, pinned from both ends. node --check, 198 node tests, make ci.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
3 files changed,
+64
−4
internal/meet/static/chat.js+16 −1internal/meettest/chat_test.go+17 −0test/browser/chat.test.mjs+31 −3
diff --git a/internal/meet/static/chat.js b/internal/meet/static/chat.js| index 06bcb02..47c4e42 100644 |
| --- a/internal/meet/static/chat.js |
| +++ b/internal/meet/static/chat.js |
| @@ -60,8 +60,23 @@ |
| // panel had no way to apply (whole-branch review, W4-1). Angle |
| // brackets and ampersands are not escaped on either side — SSE data is |
| // not HTML — so they cost one byte each here too. |
| + // U+2028 and U+2029 are the one place the two languages disagree |
| + // about what "the JSON escaping of this string" is: Go's encoder |
| + // escapes them unconditionally — SetEscapeHTML(false) included — |
| + // and JavaScript leaves them literal. Three bytes here, six there, |
| + // and a draft of 2045 a's and one U+2028 counted 2048 in the composer |
| + // and 2051 at the server, so the panel accepted a message that came |
| + // back 413 with nothing to say about it (Codex S4-7). The counter |
| + // measures what the server will measure, so it escapes them too. |
| + // |
| + // Written as escapes rather than as themselves: both characters are |
| + // line terminators in JavaScript source, and a literal one inside a |
| + // regular expression is a syntax error. |
| + const GO_ESCAPES = /[\u2028\u2029]/g; |
| + |
| function chatBytes(text) { |
| - const s = JSON.stringify(String(text == null ? '' : text)); |
| + const s = JSON.stringify(String(text == null ? '' : text)) |
| + .replace(GO_ESCAPES, (c) => (c === '\u2028' ? '\\u2028' : '\\u2029')); |
| return new TextEncoder().encode(s).length - 2; |
| } |
diff --git a/internal/meettest/chat_test.go b/internal/meettest/chat_test.go| index 8340817..c0f25d4 100644 |
| --- a/internal/meettest/chat_test.go |
| +++ b/internal/meettest/chat_test.go |
| @@ -420,6 +420,23 @@ func TestChatCapCountsTheEscapedText(t *testing.T) { |
| strings.Repeat("a", chatCap-23) + strings.Repeat(`\"`, 23), http.StatusRequestEntityTooLarge}, |
| {"angle brackets cost one byte each, not six", |
| strings.Repeat("<", chatCap), http.StatusOK}, |
| + // Codex S4-7: the two characters Go escapes even with |
| + // SetEscapeHTML(false), and JavaScript's JSON.stringify leaves |
| + // literal. Three bytes on the wire as themselves, six once Go |
| + // has written them, and the composer used to count the first |
| + // number while the server counted the second — so a draft the |
| + // panel accepted came back 413. Sent here as themselves, which |
| + // is how a paste arrives. |
| + {"a line separator at the boundary", |
| + strings.Repeat("a", chatCap-6) + "\u2028", http.StatusOK}, |
| + {"a line separator one byte past it", |
| + strings.Repeat("a", chatCap-5) + "\u2028", http.StatusRequestEntityTooLarge}, |
| + {"a paragraph separator at the boundary", |
| + strings.Repeat("a", chatCap-6) + "\u2029", http.StatusOK}, |
| + {"a paragraph separator one byte past it", |
| + strings.Repeat("a", chatCap-5) + "\u2029", http.StatusRequestEntityTooLarge}, |
| + {"the draft the composer used to accept", |
| + strings.Repeat("a", 2045) + "\u2028", http.StatusRequestEntityTooLarge}, |
| } |
| for _, c := range cases { |
| t.Run(c.name, func(t *testing.T) { |
diff --git a/test/browser/chat.test.mjs b/test/browser/chat.test.mjs| index c7401d4..e0f5a0f 100644 |
| --- a/test/browser/chat.test.mjs |
| +++ b/test/browser/chat.test.mjs |
| @@ -83,10 +83,18 @@ test('the shipped limits and sentences are the ones everything else was written |
| assert.equal(TOO_LONG, 'Message is too long.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md'); |
| }); |
| -const build = (name) => new Function(lift(src, name) + '; return ' + name + ';')(); |
| +const build = (name, prelude) => |
| + new Function((prelude || '') + lift(src, name) + '; return ' + name + ';')(); |
| const chatLine = build('chatLine'); |
| const trimLog = build('trimLog'); |
| -const chatBytes = build('chatBytes'); |
| + |
| +// chatBytes closes over the pattern of what Go escapes and JavaScript |
| +// does not, which is read out of the source rather than retyped — a |
| +// counter tested against a pattern of the test's own would prove the |
| +// test. |
| +const goEscapes = /const GO_ESCAPES = (\/\[[^/]*\]\/g);/.exec(src); |
| +assert.notEqual(goEscapes, null, 'GO_ESCAPES is not a regular expression in chat.js any more'); |
| +const chatBytes = build('chatBytes', 'const GO_ESCAPES = ' + goEscapes[1] + ';\n'); |
| // mount closes over the module's constants and its three helpers, so |
| // the harness hands it exactly those and nothing else. `document` goes |
| @@ -100,7 +108,8 @@ const mount = new Function( |
| 'SEND_FAILED', |
| 'TOO_LONG', |
| 'document', |
| - [lift(src, 'chatBytes'), lift(src, 'chatLine'), lift(src, 'trimLog'), lift(src, 'mount')].join('\n') + |
| + 'const GO_ESCAPES = ' + goEscapes[1] + ';\n' + |
| + [lift(src, 'chatBytes'), lift(src, 'chatLine'), lift(src, 'trimLog'), lift(src, 'mount')].join('\n') + |
| '\nreturn mount;', |
| )(MAX_BYTES, MAX_LINES, PINNED_PX, SEND_FAILED, TOO_LONG, undefined); |
| @@ -354,6 +363,25 @@ test('the counter measures the text as the wire will carry it', () => { |
| 'one byte past it, and the panel must say so before the server does'); |
| }); |
| +test('the two separators Go escapes and JSON.stringify does not are counted its way', () => { |
| + // Codex S4-7. U+2028 and U+2029 are the one place the two languages |
| + // disagree about what "the JSON escaping of this string" means: Go's |
| + // encoder escapes them unconditionally, SetEscapeHTML(false) included, |
| + // and JavaScript leaves them literal. Three bytes here, six there — |
| + // so 2045 a's and one U+2028 counted 2048 in the composer and 2051 at |
| + // the server, and the draft the panel accepted came back 413 with no |
| + // way to say why. |
| + for (const sep of ['\u2028', '\u2029']) { |
| + assert.equal(chatBytes(sep), 6, JSON.stringify(sep) + ' is six bytes on the wire'); |
| + assert.equal(chatBytes('a' + sep + 'b'), 8); |
| + } |
| + // The boundary Codex measured, from both sides of it. |
| + assert.equal(chatBytes('a'.repeat(MAX_BYTES - 6) + '\u2028'), MAX_BYTES); |
| + assert.equal(chatBytes('a'.repeat(MAX_BYTES - 5) + '\u2029'), MAX_BYTES + 1); |
| + assert.ok(chatBytes('a'.repeat(2045) + '\u2028') > MAX_BYTES, |
| + "the composer accepted a draft the server refuses"); |
| +}); |
| + |
| test('maxlength is not the limit: 2048 characters can be far more than 2048 bytes', () => { |
| // Exactly at the cap in ASCII, and the control would allow it. |
| assert.equal(chatBytes('a'.repeat(MAX_BYTES)), MAX_BYTES); |