Normalize literal backslash-n to a real newline in outbound messages
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.
src/apiary/tools.sgl | 31 ++++++++++++++++++++++++++-----
test/test-tools.sgl | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 101 insertions(+), 5 deletions(-)src/apiary/tools.sglmodified
parse-mention-prefix format-mention trusted-sender? normalize-literal-newlines env-or-false or-empty-env (list "") (string-split text "\n"))) ;; Normalize a caller-supplied literal backslash-n (the two chars ;; #\\ #\n) into a real newline. LLM callers frequently pass a ;; literal "\n" where they mean a line break — that's exactly the ;; contract the tools advertise ("Use \n to break lines"). Without ;; this, the literal never triggers the per-line split, and the ;; markdown->mIRC escape pass then strips the backslash, so the ;; recipient sees a stray "n" where the break belonged (observed ;; 2026-07-08: "...marketing.nnTrashed..."). Applied at the outbound ;; entry points BEFORE any markdown translation or line split, so ;; both send-message (DM) and send-channel benefit. A real newline ;; already in the text is a single #\n char and is left untouched — ;; no double-conversion. (define (normalize-literal-newlines text) (if (string? text) (string-replace text "\\n" "\n") text)) (define (assoc-or key alist default) (let ((entry (assoc key alist))) (if entry (cdr entry) default))) ((null? channels) "Error: no coordination channel configured") (else (post-channel-multiline! conn mention text (car channels))))))))) (post-channel-multiline! conn mention (normalize-literal-newlines text) (car channels))))))))) (define (post-channel-multiline! conn mention text primary) ;; The mention prefix attaches to the FIRST line only — ((not (enclave-conn-ready? conn)) "Error: enclave connection not ready") (else (let* ((formatted (if (eq? format 'plain) text (markdown->irc text))) (let* ((normalized (normalize-literal-newlines text)) (formatted (if (eq? format 'plain) normalized (markdown->irc normalized))) (lines (split-lines formatted)) (outcome (enclave-post-multiline conn nick lines)) (style (post-outcome-label outcome (length lines))))test/test-tools.sglmodified
(sigil process) (sigil mcp server) (sigil mcp protocol) (apiary markdown-irc) (apiary tools));; A literal backslash-n as it actually arrives from an LLM caller:;; the two characters #\\ and #\n, NOT a real newline.(define lit-nl (string #\\ #\n));; Drive a tools/list JSON-RPC request through the server and read;; the entries the response advertises. This is the same path the;; MCP client hits during init — proves clients see the full tool (assert-equal "worker-7" addressee) (assert-equal "ack" body)))));; ============================================================;; Literal backslash-n normalization (outbound newline fix);;;; LLM callers frequently pass a literal "\n" (two chars: #\\ #\n);; where they mean a line break. Without normalization the literal;; never triggers the per-line split, and — on the DM path — the;; markdown->mIRC escape pass then strips the backslash, leaving a;; stray "n" where the break belonged (observed 2026-07-08:;; "...marketing.nnTrashed..."). normalize-literal-newlines runs at;; both outbound entry points BEFORE any markdown translation or line;; split, so send-message (DM) and send-channel both benefit.;; ============================================================(test-group "normalize-literal-newlines" (test "converts a literal backslash-n to a real newline" (assert-equal "a\nb" (normalize-literal-newlines (string-append "a" lit-nl "b")))) (test "a real newline is left untouched (no double-conversion)" (assert-equal "a\nb" (normalize-literal-newlines "a\nb"))) (test "converts every literal occurrence" (assert-equal "one\ntwo\nthree" (normalize-literal-newlines (string-append "one" lit-nl "two" lit-nl "three")))) (test "no literal present: string returned unchanged" (assert-equal "no breaks here" (normalize-literal-newlines "no breaks here"))) (test "a lone backslash (not followed by n) is preserved" (assert-equal (string #\\ #\x) (normalize-literal-newlines (string #\\ #\x)))) (test "non-string input passes through" (assert-false (normalize-literal-newlines #f))) ;; End-to-end shape checks — these mirror what the two outbound ;; helpers do to the text before handing it to enclave-post-multiline. (test "send-channel path: normalized literal splits into multiple lines" ;; enclave-bridge-send-channel! normalizes, then splits on real "\n". (let* ((raw (string-append "first" lit-nl "second")) (lines (string-split (normalize-literal-newlines raw) "\n"))) (assert-equal 2 (length lines)) (assert-equal "first" (car lines)) (assert-equal "second" (cadr lines)))) (test "send-message (DM) path: normalize before markdown, then split" ;; enclave-bridge-send-dm! normalizes, runs markdown->irc, then ;; splits. Before the fix the markdown escape pass ate the ;; backslash and the whole message stayed a single "firstnsecond" ;; line. After it, two lines survive. (let* ((raw (string-append "first" lit-nl "second")) (rendered (markdown->irc (normalize-literal-newlines raw))) (lines (string-split rendered "\n"))) (assert-equal 2 (length lines)) (assert-equal "first" (car lines)) (assert-equal "second" (cadr lines)))) (test "regression: without normalization the DM path collapses to one line" ;; Documents the bug: feeding the raw literal straight to ;; markdown->irc yields a single line whose break became a bare "n". (let* ((raw (string-append "first" lit-nl "second")) (rendered (markdown->irc raw)) (lines (string-split rendered "\n"))) (assert-equal 1 (length lines)) (assert-equal "firstnsecond" (car lines)))));; ============================================================;; Trusted-set predicate;; ============================================================