and DSML-prefixed variants.
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls>[\s\S]*?<\/(?:\s*|\s*DSML\s*[||]\s*)?function_calls>/gi,'');
// Also remove truncated opening tags (missing closing ">" at stream tail).
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls(?:>|$)[\s\S]*$/i,'');
// Remove malformed DSML tag fragments like "<|DSML |" that can leak in tokens.
s=s.replace(/<\s*|\s*DSML\s*[||]\s*/gi,'');
return s.trim();
}
function _sanitizeThinkingDisplayText(text){
const stripped=_stripXmlToolCallsDisplay(String(text||''));
return stripped.trim();
}
function renderMd(raw){
let s=(raw||'').replace(/\r\n/g,'\n').replace(/\r/g,'\n');
// ── Entity decode: must run FIRST so > lines become > for the blockquote
// pre-pass below. LLMs sometimes emit HTML-entity-encoded output; without this
// a blockquote sent as "> text" would never be recognised as a blockquote.
s=s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,"'");
// ── Blockquote pre-pass (must run BEFORE every other markdown pass) ────────
// Group consecutive >-prefixed lines, strip the > prefix from each line,
// recursively render the stripped content with the full pipeline, and
// replace the group with a stash token. This is the only way fenced code,
// headings, hr, and ordered lists inside a blockquote can render correctly:
// the per-line passes downstream don't know about > prefixes, and by the
// time the blockquote handler used to run those passes had already mangled
// the >-prefixed lines.
//
// Walks lines (instead of using a single regex) so >-prefixed lines that
// sit inside a non-blockquote fenced block (e.g. a shell prompt in a
// ```bash``` example) are not miscaptured as a blockquote.
const _bq_stash=[];
s=(function _applyBlockquotes(input){
const lines=input.split('\n');
const out=[];
let inFence=false; // inside a non-blockquote ```...``` fence
let bqStart=-1;
const flush=(end)=>{
if(bqStart<0) return;
// Strip "> " prefix (and bare ">" → empty) from each line
const stripped=lines.slice(bqStart,end).map(l=>l.replace(/^> ?/,'')).join('\n');
// Recursive call: full pipeline on stripped content. Handles fenced
// code, headings, hr, ordered/unordered lists, nested blockquotes
// (>>) — anything that renderMd handles at the top level.
const rendered=renderMd(stripped);
_bq_stash.push(''+rendered+'
');
// Surround the token with blank lines so the paragraph splitter
// isolates it as its own chunk (otherwise the token gets wrapped
// in ...
with adjacent text, producing invalid HTML).
out.push('');
out.push('\x00Q'+(_bq_stash.length-1)+'\x00');
out.push('');
bqStart=-1;
};
for(let i=0;i/.test(line)){
if(bqStart<0) bqStart=i;
} else {
flush(i);
out.push(line);
}
}
flush(lines.length);
return out.join('\n');
})(s);
// ── MEDIA: token stash (must run first, before any other processing) ───────
// Detect MEDIA: tokens emitted by the agent (e.g. screenshots,
// generated images) and replace them with inline
or download links.
// Stashed so the path/URL is never processed as markdown.
const media_stash=[];
s=s.replace(/MEDIA:([^\s\)\]]+)/g,(_,raw_ref)=>{
media_stash.push(raw_ref);
return '\x00D'+(media_stash.length-1)+'\x00';
});
// ── End MEDIA stash ─────────────────────────────────────────────────────────
// Pre-pass: decode HTML entities first so markdown processing works correctly.
// This prevents double-escaping when LLM outputs entities like < > &
const decode=s=>s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,"'");
s=decode(s);
// Pre-pass: convert safe inline HTML tags the model may emit into their
// markdown equivalents so the pipeline can render them correctly.
// Only runs OUTSIDE fenced code blocks and backtick spans (stash + restore).
// Unsafe tags (anything not in the allowlist) are left as-is and will be
// HTML-escaped by esc() when they reach an innerHTML assignment -- no XSS risk.
// Fence stash: protect code blocks and backtick spans from all further processing.
// Must run BEFORE math_stash so $..$ inside code spans is not extracted as math.
// Split into fenced blocks (\x00P — kept stashed until after all markdown passes)
// and inline backtick spans (\x00F — restored before bold/italic so **`code`** works).
// Fenced blocks are converted to here so their content is HTML-escaped
// and never exposed to list/heading/table regexes that could corrupt the layout.
// Fixes #1154: diff/patch lines inside fenced blocks (e.g. + added, - removed)
// were matching the unordered-list regex and injecting /- inside
,
// breaking
closure and corrupting all subsequent message rendering.
const _preBlock_stash=[];
const fence_stash=[];
s=s.replace(/```([\s\S]*?)```/g,(_,raw)=>{
const m=raw.match(/^(\w[\w+-]*)\n?([\s\S]*)$/);
if(m&&m[1].trim().toLowerCase()==='mermaid'){
const id='mermaid-'+Math.random().toString(36).slice(2,10);
_preBlock_stash.push(`${esc(m[2].trim())}
`);
} else {
const lang=m?(m[1]||'').trim().toLowerCase():'';
const code=m?m[2]:raw.replace(/^\n?/,'');
const h=lang?``:'';
const langAttr=lang?` class="language-${esc(lang)}"`:'';
// For diff/patch blocks, wrap each line in a colored span
if(lang==='diff'||lang==='patch'){
const colored=esc(code.replace(/\n$/,'')).split('\n').map(line=>{
if(line.startsWith('@@')) return `${line}`;
if(line.startsWith('+')) return `${line}`;
if(line.startsWith('-')) return `${line}`;
return `${line}`;
}).join('\n');
_preBlock_stash.push(`${h}${colored}
`);
// For JSON/YAML blocks, add tree-view placeholder with raw data
} else if(lang==='json'||lang==='yaml'){
const rawCode=esc(code.replace(/\n$/,''));
const blockId='tree-'+Math.random().toString(36).slice(2,10);
_preBlock_stash.push(``);
// CSV blocks → render as styled table
} else if(lang==='csv'){
const rows=code.replace(/\n$/,'').split('\n').filter(r=>r.trim());
if(rows.length>=2){
const headers=rows[0].split(',').map(c=>c.trim());
const body=rows.slice(1).map(r=>''+r.split(',').map(c=>`| ${esc(c.trim())} | `).join('')+'
').join('');
_preBlock_stash.push(`${h}${headers.map(h=>`| ${esc(h)} | `).join('')}
${body}
`);
} else {
_preBlock_stash.push(`${h}${esc(code.replace(/\n$/,''))}
`);
}
} else {
_preBlock_stash.push(`${h}${esc(code.replace(/\n$/,''))}
`);
}
}
return '\x00P'+(_preBlock_stash.length-1)+'\x00';
});
s=s.replace(/`([^`\n]+)`/g,(_,c)=>{fence_stash.push(''+esc(c)+'');return '\x00F'+(fence_stash.length-1)+'\x00';});
// Math stash: protect $$..$$ and $..$ from markdown processing
// Runs AFTER fence_stash so backtick code spans protect their dollar-sign contents
const math_stash=[];
// Display math: $$...$$ (must come before inline to avoid mis-parsing)
s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Inline math: $...$ — require non-space at boundaries to avoid false positives
// e.g. "costs $5 and $10" should not trigger (space after opening $)
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Also stash \(...\) and \[...\] LaTeX delimiters
s=s.replace(/\\\\\((.+?)\\\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
s=s.replace(/\\\\\[(.+?)\\\\\]/gs,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Safe tag → markdown equivalent (these produce the same output as **text** etc.)
// Stash raw blocks so the inline rewrite below does not run
// inside them. Running that rewrite in content can introduce stray
// backticks for multiline code and break subsequent code-box rendering.
const rawPreStash=[];
s=s.replace(/(]*>[\s\S]*?<\/pre>)/gi,m=>{rawPreStash.push(m);return `\x00R${rawPreStash.length-1}\x00`;});
s=s.replace(/([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');
s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*');
s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
s=s.replace(/
/gi,'\n');
s=s.replace(/\x00R(\d+)\x00/g,(_,i)=>rawPreStash[+i]);
// Inline backtick spans: restore tags produced in the stash callback above.
// Must happen BEFORE bold/italic so **`code`** → code.
s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]);
// inlineMd: process bold/italic/code/links within a single line of text.
// Used inside list items and blockquotes where the text may already contain
// HTML from the pre-pass → bold pipeline, so we cannot call esc() directly.
function inlineMd(t){
// Stash backtick code spans first so bold/italic never esc() their content
const _code_stash=[];
t=t.replace(/`([^`\n]+)`/g,(_,x)=>{_code_stash.push(`${esc(x)}`);return `\x00C${_code_stash.length-1}\x00`;});
t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)}`);
t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)}`);
t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)}`);
// Strikethrough: ~~text~~ → text
t=t.replace(/~~(.+?)~~/g,(_,x)=>`${esc(x)}`);
// #487: Image pass — runs while code stash is active so  inside
// backticks stays protected as a \x00C token and is never rendered as
.
// Must run before _code_stash restore and before _link_stash so the image
// is not consumed by the [label](url) link regex.
t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`
`);
// Stash rendered
tags so autolink never matches URLs inside src=
const _img_stash=[];
t=t.replace(/(
]*>)/g,m=>{_img_stash.push(m);return `\x00G${_img_stash.length-1}\x00`;});
t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]);
// Stash [label](url) links before autolink so the URL in href= is not re-linked
const _link_stash=[];
t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;});
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;});
t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]);
t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]);
// Escape any plain text that isn't already wrapped in a tag we produced
// by escaping bare < > that are not part of our own tags
const SAFE_INLINE=/^<\/?(strong|em|del|code|a|img)([\s>]|$)/i;
t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag));
return t;
}
// Stash tags from the backtick pass above so the outer bold/italic
// regexes don't esc() their content (e.g. **`code`** → code)
const _ob_stash=[];
s=s.replace(/(]*>[\s\S]*?<\/code>)/g,m=>{_ob_stash.push(m);return `\x00O${_ob_stash.length-1}\x00`;});
s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`${esc(t)}`);
s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`${esc(t)}`);
s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)}`);
s=s.replace(/~~(.+?)~~/g,(_,t)=>`${esc(t)}`);
s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]);
s=s.replace(/^###### (.+)$/gm,(_,t)=>`${inlineMd(t)}
`).replace(/^##### (.+)$/gm,(_,t)=>`${inlineMd(t)}
`).replace(/^#### (.+)$/gm,(_,t)=>`${inlineMd(t)}
`).replace(/^### (.+)$/gm,(_,t)=>`${inlineMd(t)}
`).replace(/^## (.+)$/gm,(_,t)=>`${inlineMd(t)}
`).replace(/^# (.+)$/gm,(_,t)=>`${inlineMd(t)}
`);
s=s.replace(/^---+$/gm,'
');
// (Blockquotes are handled by the pre-pass at the top of renderMd, before
// fence_stash. The per-line passes below never see > prefixes.)
// B8: improved list handling supporting up to 2 levels of indentation
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
const lines=block.trimEnd().split('\n');
let html='';
for(const l of lines){
const indent=/^ {2,}/.test(l);
const text=l.replace(/^ {0,4}[-*+] /,'');
let _ih;
if(/^\[x\] /i.test(text)) _ih='✅ '+inlineMd(text.slice(4));
else if(/^\[ \] /.test(text)) _ih='☐ '+inlineMd(text.slice(4));
else _ih=inlineMd(text);
if(indent) html+=`- ${_ih}
`;
else html+=`- ${_ih}
`;
}
return html+'
';
});
// Ordered lists: use value= on each - so the correct number is preserved
// even when blank lines between items cause the paragraph splitter to place
// each item in its own
container — without value= every restarts
// at 1, producing "1. 1. 1." instead of "1. 2. 3." (#886).
s=s.replace(/((?:^(?: )?\d+\. .+\n?)+)/gm,block=>{
const lines=block.trimEnd().split('\n');
let html='';
for(const l of lines){
const numMatch=l.match(/^\s*(\d+)\. /);
const num=numMatch?parseInt(numMatch[1],10):null;
const text=l.replace(/^ {0,4}\d+\. /,'');
const valAttr=num!==null?` value="${num}"`:'';
html+=`- ${inlineMd(text)}
`;
}
return html+'
';
});
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
// NOTE: table pass runs BEFORE outer link pass so [label](url) in table cells
// is handled by inlineMd() only — prevents double-linking.
s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{
const rows=block.trim().split('\n').filter(r=>r.trim());
if(rows.length<2)return block;
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
if(!isSep(rows[1]))return block;
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${inlineMd(c.trim())} | `).join('');
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${inlineMd(c.trim())} | `).join('');
const header=`${parseHeader(rows[0])}
`;
const body=rows.slice(2).map(r=>`${parseRow(r)}
`).join('');
return ``;
});
// #487: Outer image pass — handles  in plain paragraphs (outside tables/lists).
// Runs AFTER the table pass (images in table cells are handled by inlineMd() above).
// Runs BEFORE the outer [label](url) link pass so the image is not consumed as a plain link.
s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`
`);
// Outer link pass for labeled links in plain paragraphs (outside table cells).
// Runs AFTER the table pass so table cells are processed by inlineMd() only.
// Stash existing tags first to avoid re-linking already-linked URLs.
const _a_stash=[];
s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;});
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`);
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
// Sanitize any remaining HTML tags. The renderer intentionally returns
// HTML and inserts it with innerHTML later, so tag names alone are not enough:
// raw/model-provided HTML like
or
// must lose executable attributes and dangerous schemes while preserving the
// small set of attributes generated by this markdown pipeline.
// Reference only — documents the allowed tag set. Superseded by _tag() allowlists.
// Tests verify this list is complete; _tag() enforces it.
const SAFE_TAGS=/^<\/?(?:strong|em|del|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div|span|img)([\s>]|$)/i;
function _safeAttrValue(v){
return String(v||'').replace(/"/g,'"').replace(/'/g,"'").replace(/&/g,'&').trim();
}
function _isSafeUrl(v, img){
const raw=_safeAttrValue(v);
const compact=raw.replace(/[\u0000-\u001f\u007f\s]+/g,'').toLowerCase();
if(!compact) return false;
if(/^(javascript|data|vbscript):/i.test(compact)) return false;
if(/^https?:\/\//i.test(raw)) return true;
if(img && /^api\//i.test(raw)) return true;
if(!img && (/^api\//i.test(raw) || /^#/.test(raw))) return true;
return false;
}
function _attrs(raw){
const out={};
String(raw||'').replace(/([a-zA-Z0-9:_-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>`]+)))?/g,(_,k,dq,sq,bare)=>{
out[String(k).toLowerCase()]=dq!==undefined?dq:(sq!==undefined?sq:(bare!==undefined?bare:''));
return '';
});
return out;
}
function _cls(v, allowed){
const got=String(v||'').split(/\s+/).filter(c=>allowed.includes(c));
return got.length?` class="${esc(got.join(' '))}"`:'';
}
function _tag(tag){
const m=String(tag||'').match(/^<\s*(\/)?\s*([a-zA-Z][\w:-]*)([\s\S]*?)(\/)?\s*>$/);
if(!m) return esc(tag);
const closing=!!m[1];
const name=m[2].toLowerCase();
const rawAttrs=m[3]||'';
const plain=['strong','em','del','pre','h1','h2','h3','h4','h5','h6','ul','ol','table','thead','tbody','tr','th','td','blockquote','p','br','hr'];
if(closing) return plain.includes(name)||['a','div','span','li','code'].includes(name)?`${name}>`:'';
if(name==='code'){
const a=_attrs(rawAttrs);
const cls=/^language-[a-z0-9_+-]+$/i.test(a.class||'')?` class="${esc(a.class)}"`:'';
return ``;
}
if(plain.includes(name)) return `<${name}>`;
const a=_attrs(rawAttrs);
if(name==='li'){
const value=/^\d+$/.test(a.value||'')?` value="${esc(a.value)}"`:'';
const style=(a.style||'').replace(/\s+/g,'').toLowerCase()==='margin-left:16px'?` style="margin-left:16px"`:'';
return `- `;
}
if(name==='span'){
return ``;
}
if(name==='div'){
const cls=_cls(a.class,['pre-header','mermaid-block','katex-block']);
const mermaid=a['data-mermaid-id']?` data-mermaid-id="${esc(a['data-mermaid-id'])}"`:'';
const katex=a['data-katex']==='display'?' data-katex="display"':'';
return `