Commit47ddee2aRecorded8 Jul 2026Repositoryapiary

Normalize literal backslash-n to a real newline in outbound messages

Message

LLM callers frequently pass a literal n (two chars: backslash + n) instead of a real newline, which the tool descriptions explicitly invite ("Use n to break lines"). The literal never matched the real-newline split that produces per-line PRIVMSGs, and on the DM path markdown->irc ran first and its backslash-escape branch stripped the backslash, so the recipient saw a stray "n" where the line break belonged (e.g. "...marketing.nnTrashed...").

Add normalize-literal-newlines and apply it at both outbound entry points (send-channel and send-message/DM) before any markdown translation or line split. A real newline already present is a single char and is left untouched. Add a normalize-literal-newlines test group covering both paths plus a regression test for the old collapse.

Changed
 src/apiary/tools.sgl | 31 ++++++++++++++++++++++++++-----
 test/test-tools.sgl  | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 101 insertions(+), 5 deletions(-)
Diff
src/apiary/tools.sglmodified
@@ -56,6 +56,7 @@
56
parse-mention-prefix
57
format-mention
58
trusted-sender?
+59
normalize-literal-newlines
60
env-or-false
61
or-empty-env
62
@@ -150,6 +151,23 @@
151
(list "")
152
(string-split text "\n")))
153
+154
;; Normalize a caller-supplied literal backslash-n (the two chars
+155
;; #\\ #\n) into a real newline. LLM callers frequently pass a
+156
;; literal "\n" where they mean a line break — that's exactly the
+157
;; contract the tools advertise ("Use \n to break lines"). Without
+158
;; this, the literal never triggers the per-line split, and the
+159
;; markdown->mIRC escape pass then strips the backslash, so the
+160
;; recipient sees a stray "n" where the break belonged (observed
+161
;; 2026-07-08: "...marketing.nnTrashed..."). Applied at the outbound
+162
;; entry points BEFORE any markdown translation or line split, so
+163
;; both send-message (DM) and send-channel benefit. A real newline
+164
;; already in the text is a single #\n char and is left untouched —
+165
;; no double-conversion.
+166
(define (normalize-literal-newlines text)
+167
(if (string? text)
+168
(string-replace text "\\n" "\n")
+169
text))
+170
171
(define (assoc-or key alist default)
172
(let ((entry (assoc key alist)))
173
(if entry (cdr entry) default)))
@@ -583,8 +601,10 @@
601
((null? channels)
602
"Error: no coordination channel configured")
603
(else
586
(post-channel-multiline! conn mention text
587
(car channels)))))))))
+604
(post-channel-multiline!
+605
conn mention
+606
(normalize-literal-newlines text)
+607
(car channels)))))))))
608
609
(define (post-channel-multiline! conn mention text primary)
610
;; The mention prefix attaches to the FIRST line only —
@@ -648,9 +668,10 @@
668
((not (enclave-conn-ready? conn))
669
"Error: enclave connection not ready")
670
(else
651
(let* ((formatted (if (eq? format 'plain)
652
text
653
(markdown->irc text)))
+671
(let* ((normalized (normalize-literal-newlines text))
+672
(formatted (if (eq? format 'plain)
+673
normalized
+674
(markdown->irc normalized)))
675
(lines (split-lines formatted))
676
(outcome (enclave-post-multiline conn nick lines))
677
(style (post-outcome-label outcome (length lines))))
test/test-tools.sglmodified
@@ -4,8 +4,13 @@
4
(sigil process)
5
(sigil mcp server)
6
(sigil mcp protocol)
+7
(apiary markdown-irc)
8
(apiary tools))
9
+10
;; A literal backslash-n as it actually arrives from an LLM caller:
+11
;; the two characters #\\ and #\n, NOT a real newline.
+12
(define lit-nl (string #\\ #\n))
+13
14
;; Drive a tools/list JSON-RPC request through the server and read
15
;; the entries the response advertises. This is the same path the
16
;; MCP client hits during init — proves clients see the full tool
@@ -204,6 +209,76 @@
209
(assert-equal "worker-7" addressee)
210
(assert-equal "ack" body)))))
211
+212
;; ============================================================
+213
;; Literal backslash-n normalization (outbound newline fix)
+214
;;
+215
;; LLM callers frequently pass a literal "\n" (two chars: #\\ #\n)
+216
;; where they mean a line break. Without normalization the literal
+217
;; never triggers the per-line split, and — on the DM path — the
+218
;; markdown->mIRC escape pass then strips the backslash, leaving a
+219
;; stray "n" where the break belonged (observed 2026-07-08:
+220
;; "...marketing.nnTrashed..."). normalize-literal-newlines runs at
+221
;; both outbound entry points BEFORE any markdown translation or line
+222
;; split, so send-message (DM) and send-channel both benefit.
+223
;; ============================================================
+224
+225
(test-group "normalize-literal-newlines"
+226
(test "converts a literal backslash-n to a real newline"
+227
(assert-equal "a\nb"
+228
(normalize-literal-newlines (string-append "a" lit-nl "b"))))
+229
+230
(test "a real newline is left untouched (no double-conversion)"
+231
(assert-equal "a\nb"
+232
(normalize-literal-newlines "a\nb")))
+233
+234
(test "converts every literal occurrence"
+235
(assert-equal "one\ntwo\nthree"
+236
(normalize-literal-newlines
+237
(string-append "one" lit-nl "two" lit-nl "three"))))
+238
+239
(test "no literal present: string returned unchanged"
+240
(assert-equal "no breaks here"
+241
(normalize-literal-newlines "no breaks here")))
+242
+243
(test "a lone backslash (not followed by n) is preserved"
+244
(assert-equal (string #\\ #\x)
+245
(normalize-literal-newlines (string #\\ #\x))))
+246
+247
(test "non-string input passes through"
+248
(assert-false (normalize-literal-newlines #f)))
+249
+250
;; End-to-end shape checks — these mirror what the two outbound
+251
;; helpers do to the text before handing it to enclave-post-multiline.
+252
+253
(test "send-channel path: normalized literal splits into multiple lines"
+254
;; enclave-bridge-send-channel! normalizes, then splits on real "\n".
+255
(let* ((raw (string-append "first" lit-nl "second"))
+256
(lines (string-split (normalize-literal-newlines raw) "\n")))
+257
(assert-equal 2 (length lines))
+258
(assert-equal "first" (car lines))
+259
(assert-equal "second" (cadr lines))))
+260
+261
(test "send-message (DM) path: normalize before markdown, then split"
+262
;; enclave-bridge-send-dm! normalizes, runs markdown->irc, then
+263
;; splits. Before the fix the markdown escape pass ate the
+264
;; backslash and the whole message stayed a single "firstnsecond"
+265
;; line. After it, two lines survive.
+266
(let* ((raw (string-append "first" lit-nl "second"))
+267
(rendered (markdown->irc (normalize-literal-newlines raw)))
+268
(lines (string-split rendered "\n")))
+269
(assert-equal 2 (length lines))
+270
(assert-equal "first" (car lines))
+271
(assert-equal "second" (cadr lines))))
+272
+273
(test "regression: without normalization the DM path collapses to one line"
+274
;; Documents the bug: feeding the raw literal straight to
+275
;; markdown->irc yields a single line whose break became a bare "n".
+276
(let* ((raw (string-append "first" lit-nl "second"))
+277
(rendered (markdown->irc raw))
+278
(lines (string-split rendered "\n")))
+279
(assert-equal 1 (length lines))
+280
(assert-equal "firstnsecond" (car lines)))))
+281
282
;; ============================================================
283
;; Trusted-set predicate
284
;; ============================================================