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:
committed by
brooklyn!
parent
e0bc2c1ce9
commit
db1728d35d
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user