fix(desktop): keep authored link labels and identifier casing

MarkdownLink dropped formatted link labels: childrenToText only handled
plain strings, so an inline-code label ([`v1.0.1`](url)) fell through to a
title fetch or a URL-slug fallback that title-cased the identifier.
Flatten element children so the authored label wins with exact casing,
and keep the casing of separator-less slug tokens that look like
identifiers (digits, dots, mixed case) instead of title-casing them.

Fixes https://github.com/NousResearch/hermes-agent/issues/121321
This commit is contained in:
Brooklyn Nicholson
2026-09-27 06:47:26 -05:00
committed by brooklyn!
parent e0bc2c1ce9
commit db1728d35d
4 changed files with 112 additions and 8 deletions

View File

@@ -0,0 +1,66 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MarkdownTextContent } from './markdown-text'
// Regression for #121321: authored markdown link labels are the text the
// model wrote, and formatted labels count. An inline-code label
// ([`v1.0.1`](url)) used to be dropped by childrenToText (it only handled
// plain strings), so the link fell through to a title fetch or a URL-slug
// fallback label that title-cased the identifier (`V1.0.1`). The authored
// label must win, with its exact casing preserved. Bare URLs keep their
// identifier casing too when no title is fetched (`README.md`, not
// `README.Md`).
describe('MarkdownLink authored labels', () => {
afterEach(cleanup)
it('preserves an inline-code link label with its exact casing', async () => {
render(
<MarkdownTextContent isRunning={false} text="Tagged [`v1.0.1`](https://example.com/releases/tag/v1.0.1)." />
)
await screen.findByText('v1.0.1')
const anchor = screen.getByRole('link') as HTMLAnchorElement
expect(anchor.getAttribute('href')).toBe('https://example.com/releases/tag/v1.0.1')
expect(anchor.textContent).toContain('v1.0.1')
})
it('prefers the authored inline-code label over a fetched page title', async () => {
const fetchLinkTitle = vi.fn().mockResolvedValue('Releases · example')
;(window as unknown as { hermesDesktop: object }).hermesDesktop = {
fetchLinkTitle,
openExternal: vi.fn().mockResolvedValue(undefined)
}
try {
render(
<MarkdownTextContent isRunning={false} text="Tagged [`v1.0.1`](https://example.com/releases/tag/v1.0.1)." />
)
await screen.findByText('v1.0.1')
expect(fetchLinkTitle).not.toHaveBeenCalled()
} finally {
delete (window as unknown as { hermesDesktop?: object }).hermesDesktop
}
})
it('keeps a bare URL fully visible with its exact casing', async () => {
render(<MarkdownTextContent isRunning={false} text="See https://example.com/repository/blob/main/README.md" />)
const anchor = (await screen.findByRole('link')) as HTMLAnchorElement
expect(anchor.getAttribute('href')).toBe('https://example.com/repository/blob/main/README.md')
// The full URL is the label (#121007), so its identifier casing is
// exactly what the sender wrote — never a title-cased `README.Md`.
expect(anchor.textContent).toContain('README.md')
expect(anchor.textContent).not.toContain('README.Md')
})
it('keeps a plain authored label untouched', () => {
render(<MarkdownTextContent isRunning={false} text="[v1.0.1](https://example.com/releases/tag/v1.0.1)" />)
expect(screen.getByRole('link').textContent).toContain('v1.0.1')
})
})

View File

@@ -8,7 +8,7 @@ import {
tailBoundedRemend
} from '@assistant-ui/react-streamdown'
import type { code as streamdownCode } from '@streamdown/code'
import { type ComponentProps, memo, type ReactNode, useEffect, useMemo, useState } from 'react'
import { type ComponentProps, isValidElement, memo, type ReactNode, useEffect, useMemo, useState } from 'react'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
@@ -249,13 +249,29 @@ function MediaPlaybackAttachment({ path }: { path: string }) {
)
}
// Authored labels can be formatted markdown — an inline-code label like
// [`v1.0.1`](url) arrives as a <code> element, not a plain string. Extract
// the text so MarkdownLink can pass it as `fallbackLabel`; dropping it sent
// the link down the title-fetch / URL-slug fallback path instead (#121321).
function childrenToText(children: unknown): string {
if (typeof children === 'string' || typeof children === 'number') {
return String(children).trim()
return flattenChildrenToText(children).trim()
}
function flattenChildrenToText(node: unknown): string {
if (node === null || node === undefined || typeof node === 'boolean') {
return ''
}
if (Array.isArray(children) && children.every(c => typeof c === 'string' || typeof c === 'number')) {
return children.join('').trim()
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(flattenChildrenToText).join('')
}
if (isValidElement<{ children?: unknown }>(node)) {
return flattenChildrenToText(node.props.children)
}
return ''

View File

@@ -70,6 +70,17 @@ describe('external link helpers', () => {
).toBe('From Fajardo Icacos Island Full Day Catamaran Trip')
})
// Regression for #121321: a separator-less slug token that looks like a
// case-sensitive identifier (a digit, a dot, or mixed case — release
// tags, filenames) must keep its exact casing; title-casing it invented a
// different identifier (`V1.0.1`, `README.Md`) than the one authored. A
// plain lowercase word token is not an identifier and still title-cases.
it('keeps identifier casing in separator-less slug tokens but still title-cases words', () => {
expect(urlSlugTitleLabel('https://example.com/releases/tag/v1.0.1')).toBe('v1.0.1')
expect(urlSlugTitleLabel('https://example.com/repository/blob/main/README.md')).toBe('README.md')
expect(urlSlugTitleLabel('https://example.com/p/quantumcomputing')).toBe('Quantumcomputing')
})
it('filters out local/non-http targets for title fetches', () => {
expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true)
expect(isTitleFetchable('http://localhost:5174')).toBe(false)

View File

@@ -104,10 +104,21 @@ export function urlSlugTitleLabel(value: string): string {
continue
}
const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase())
// Title-case word slugs (`some-guide` → `Some Guide`), but keep the
// exact casing of a separator-less token that looks like a
// case-sensitive identifier — it carries a digit, a dot, or mixed case,
// as in a release tag (`v1.0.1`) or a filename (`README.md`) — so the
// link never invents a different identifier. A plain lowercase word
// (`quantumcomputing` → `Quantumcomputing`) still title-cases. (#121321)
const looksLikeIdentifier = /\d/.test(cleaned) || /[A-Z]/.test(cleaned) || cleaned.includes('.')
if (titled.length >= 4) {
return titled
const label =
looksLikeIdentifier && !cleaned.includes(' ')
? cleaned
: cleaned.replace(/\b[a-z]/g, c => c.toUpperCase())
if (label.length >= 4) {
return label
}
}