fix(desktop): keep bare loopback URLs in assistant prose

A loopback URL the model writes into a sentence is user-facing content:
"the dev server is at http://localhost:3000" rendered with the address
deleted. The prose pass stripped any bare loopback URL that lacked a
path segment, so only the fenced preview hand-off path survived. Drop
the prose-strip regex; the only remaining suppression is a fenced block
whose whole body is the loopback URL (LOCAL_PREVIEW_ONLY_RE), which the
preview widget already paints.

Fixes https://github.com/NousResearch/hermes-agent/issues/121683
This commit is contained in:
Brooklyn Nicholson
2026-09-27 06:33:34 -05:00
committed by brooklyn!
parent f039f028f2
commit 7ef36b5518
3 changed files with 45 additions and 8 deletions

View File

@@ -16,8 +16,9 @@ describe('preprocessMarkdown', () => {
expect(output).not.toContain('```')
expect(output).toContain("Here's your scene:")
// Bare localhost URLs (with or without trailing slash) are still stripped.
expect(output).not.toContain('http://localhost:8812/')
// Loopback URLs in prose are user-facing content (#121683): the address
// autolinks instead of being deleted from the sentence.
expect(output).toContain('<http://localhost:8812/>')
expect(output).toContain('- **Multicolored cube**')
})
@@ -34,8 +35,9 @@ describe('preprocessMarkdown', () => {
const output = preprocessMarkdown(input)
expect(output).not.toContain('```')
// Bare localhost URLs (with or without trailing slash) are still stripped.
expect(output).not.toContain('http://localhost:8812/')
// Loopback URLs in prose are user-facing content (#121683): the address
// autolinks instead of being deleted from the sentence.
expect(output).toContain('<http://localhost:8812/>')
expect(output).toContain('- **Scroll wheel** - zoom')
})

View File

@@ -105,6 +105,39 @@ describe('preprocessMarkdown / raw-URL autolinking inside markdown links', () =>
})
})
describe('preprocessMarkdown / bare loopback URLs in prose', () => {
// A loopback URL the model writes into a sentence is user-facing content —
// "the dev server is at http://localhost:3000" must render the address,
// not leave a hole (#121683). The only legitimate suppression is the fenced
// preview hand-off (a whole fenced block whose body is just the URL),
// owned by LOCAL_PREVIEW_ONLY_RE in normalizeFenceBlocks.
it.each([
['Bare autolink: http://localhost:3000', 'http://localhost:3000'],
['With a path: http://localhost:3000/dashboard', 'http://localhost:3000/dashboard'],
['Loopback IP: http://127.0.0.1:8080', 'http://127.0.0.1:8080'],
['Wildcard bind: http://0.0.0.0:8000', 'http://0.0.0.0:8000'],
['IPv6 loopback: http://[::1]:8080/status', 'http://[::1]:8080/status'],
['Trailing slash: http://localhost:3000/ - panel', 'http://localhost:3000/'],
['Server at http://localhost:3000 and docs at https://example.com', ['http://localhost:3000', 'https://example.com']]
])('preserves bare loopback URLs in prose: %s', (input, expected) => {
expect(hrefs(input)).toEqual([expected].flat())
})
it('keeps a markdown-labeled loopback link intact', () => {
const input = '[my dev server](http://localhost:3000)'
expect(preprocessMarkdown(input)).toBe(input)
expect(hrefs(input)).toEqual(['http://localhost:3000'])
})
it('still drops a fenced block whose whole body is a loopback URL (preview hand-off)', () => {
const input = 'Before\n\n```\nhttp://localhost:3000\n```\n\nAfter'
expect(preprocessMarkdown(input)).toBe('Before\n\n\nAfter')
expect(hrefs(input)).toEqual([])
})
})
describe('preprocessMarkdown / bare-URL trailing punctuation', () => {
it.each([
['Visit https://example.com/a.', 'https://example.com/a'],

View File

@@ -97,9 +97,12 @@ const MARKDOWN_LINK_SPLIT_RE = new RegExp(
'gm'
)
// Only strip bare localhost root URLs in prose. URLs with actual path segments
// (e.g. http://localhost:8080/piwo) are user-facing content and must survive.
const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?(?=\s|$)/gi
// A fenced block whose entire body is a loopback URL is the preview-pane
// hand-off: the widget already paints that address, so the whole block is
// dropped instead of painting the raw text twice. Bare loopback URLs in
// PROSE are user-facing content and must survive (#121683) — "dev server at
// http://localhost:3000" is the address the reader needs, wherever it sits
// in the sentence.
const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i
const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i
// Autolink-shaped spans (bare or angle-bracketed http(s) URLs) that must be
@@ -445,7 +448,6 @@ function rewriteProseSegment(segment: string): string {
escapeUnknownHtmlLikeTags(
segment
.replace(/`{3,}/g, '')
.replace(LOCAL_PREVIEW_URL_RE, '$1')
.replace(CITATION_TRANSPORT_MARKER_RE, '')
.replace(CITATION_MARKER_RE, '')
)