Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
import { scalingRatioOver4x } from '@/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers'
import { sanitizeChatDisplayContent } from './chat-sanitize'

describe('sanitizeChatDisplayContent', () => {
it('unwraps workspace resource tags from inline code spans', () => {
Expand All @@ -23,6 +23,35 @@ describe('sanitizeChatDisplayContent', () => {
)
})

it.each(['source', 'workspace_resource'])('preserves backticks inside %s JSON strings', (tag) => {
const payload = JSON.stringify({
title: 'Run `bun test`',
snippet: 'Quoted "commands" and a \\path with `backticks`',
})
const chip = `<${tag}>${payload}</${tag}>`

expect(sanitizeChatDisplayContent(`\`Evidence ${chip}.\``)).toBe(`Evidence ${chip}.`)
expect(sanitizeChatDisplayContent(`\`${chip} done`)).toBe(`${chip} done`)
expect(sanitizeChatDisplayContent(`${chip}\` done`)).toBe(`${chip} done`)
expect(sanitizeChatDisplayContent(`\`before\`${chip}\`after\``)).toBe(
`\`before\`${chip}\`after\``
)
})

it('treats tag markers inside JSON strings as payload', () => {
const payload = JSON.stringify({ snippet: 'Use `<source>` and `</source>` markers' })
const chip = `<source>${payload}</source>`

expect(sanitizeChatDisplayContent(`\`See ${chip}\``)).toBe(`See ${chip}`)
})

it('leaves a fenced source example with payload backticks intact', () => {
const payload = JSON.stringify({ snippet: 'Run `bun test`' })
const content = `Example:\n\`\`\`json\n<source>${payload}</source>\n\`\`\`\nDone.`

expect(sanitizeChatDisplayContent(content)).toBe(content)
})

it('removes hidden internal references wrapped in inline code', () => {
const content = 'Read `internal/tool-results/read-1.md` and found the issue.'

Expand Down Expand Up @@ -105,6 +134,16 @@ describe('sanitizeChatDisplayContent', () => {
expect(scalingRatioOver4x((content) => sanitizeChatDisplayContent(content))).toBeLessThan(8)
})

it('stays linear on repeated unclosed JSON chip bodies', () => {
expect(
scalingRatioOver4x((content) =>
sanitizeChatDisplayContent(
content.replaceAll('The <workspace_resource> tag is used here. ', '<source>{"snippet":"')
)
)
).toBeLessThan(8)
})

it('still unwraps a real tag that carries a stray backtick on one side only', () => {
// The case the unpaired strip is actually for: the model backticked the
// opener but not the closer (or vice versa), which would block the chip.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,65 +1,66 @@
const HIDDEN_INLINE_REFERENCE_PATTERN =
/`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g

/** JSON strings own their escaped quotes, backticks, and any quoted tag markers. */
const JSON_STRING_SOURCE = String.raw`"(?:[^"\\\r\n]|\\[^\r\n])*"`

/**
* A complete inline-chip tag — `<workspace_resource>` or `<source>` — as
* opener, payload, closer. Both are JSON-bodied tags the model places inside a
* sentence, so both attract the same stray backticks.
*
* Two constraints on the payload, both load-bearing:
*
* - **No backtick.** A payload is JSON and carries none, so this is what tells a
* real tag from prose MENTIONING the tag name — a message explaining the
* syntax writes the opener and the closer as two separately backticked spans.
* - **No nested opener**, via the negative lookahead. A cost bound rather than a
* correctness rule: a lazy scan allowed to cross an opener restarts from every
* opener, so a message repeating the tag name is quadratic — on the main
* thread, for every streamed chunk.
*
* Accepted trade: a resource whose title or path itself contains a backtick is
* not matched, so it renders as text rather than a chip. That costs one chip and
* is rare; the failure it replaces corrupts a whole message and is common.
* Complete chip tags consume JSON strings atomically. Outside strings, a new
* opener or backtick ends the candidate, so prose mentions cannot join into a
* tag and repeated unclosed openers cannot repeatedly scan the same suffix.
*/
const COMPLETE_TAG_SOURCE =
'<(?<chipTag>workspace_resource|source)>(?:(?!<\\k<chipTag>>)[^`])*?<\\/\\k<chipTag>>'
const COMPLETE_TAG_SOURCE = `<(?<chipTag>workspace_resource|source)>\\s*\\{(?:${JSON_STRING_SOURCE}|[^"\`<])*?\\}\\s*</\\k<chipTag>>`

/** Non-global so {@link RegExp.test} has no `lastIndex` to carry between calls. */
const COMPLETE_INLINE_CHIP_TAG = new RegExp(COMPLETE_TAG_SOURCE)
const CHIP_OR_CODE_DELIMITER = new RegExp(`${COMPLETE_TAG_SOURCE}|\`|\n`, 'g')

/**
* One left-to-right pass over the two things that can own a backtick: an inline
* code span, and a tag with a stray backtick pressed against it.
*
* ONE pass is the design. Two separate passes each have to guess which backticks
* belong together, and every previous arrangement of this file got a different
* case wrong — a span two words away, a code fence, then a span sitting flush
* against the tag. Here a span consumes its own delimiters as the scan reaches
* them, so `` `config.json`<tag> `` keeps its pair without a special case.
*
* The trailing backtick is only taken when no further backtick follows on the
* line; otherwise it is not a stray at all but the opener of the next span, and
* `` <tag>`config.json` `` would lose that span's delimiter. A LEADING backtick
* needs no such guard, because a backtick that closes a span is consumed as part
* of that span. Of the two, only the trailing lookahead is pinned by a test —
* swapping the alternatives changes behaviour only for a span that both opens
* flush against a tag and closes elsewhere, which no fixture covers.
* Pair Markdown delimiters outside chip payloads in one forward pass. A pair
* containing a chip is unwrapped; a lone delimiter is removed only when flush
* against a chip. Neighbouring code spans and multiline fences keep their pairs.
*/
const CODE_SPAN_OR_FLANKED_TAG = new RegExp(
`\`[^\`\\n]*\`|\`?(${COMPLETE_TAG_SOURCE})(?:\`(?![^\`\\n]*\`))?`,
'g'
)

export function sanitizeChatDisplayContent(content: string): string {
return content
.replace(CODE_SPAN_OR_FLANKED_TAG, (match, tag?: string) => {
// A tag with stray backticks against it: keep the tag, drop the strays.
if (tag !== undefined) return tag
const removedDelimiters: number[] = []
let openingTick = -1
let containsChip = false
let adjacentToChip = false
let lastChipEnd = -1

for (const match of content.matchAll(CHIP_OR_CODE_DELIMITER)) {
const index = match.index
if (match.groups?.chipTag) {
if (openingTick !== -1) {
containsChip = true
adjacentToChip ||= index === openingTick + 1
}
lastChipEnd = index + match[0].length
continue
}

if (match[0] === '\n') {
if (openingTick !== -1 && adjacentToChip) removedDelimiters.push(openingTick)
openingTick = -1
lastChipEnd = -1
continue
}

if (openingTick === -1) {
openingTick = index
containsChip = false
adjacentToChip = lastChipEnd === index
} else {
if (containsChip) removedDelimiters.push(openingTick, index)
openingTick = -1
}
}

if (openingTick !== -1 && adjacentToChip) removedDelimiters.push(openingTick)

// A code span. Unwrap it only when it genuinely holds a tag — the parser
// lifts the tag out either way, so leaving the delimiters would strand a
// pair of backticks around a hole. Anything else is someone else's span.
const inner = match.slice(1, -1)
return COMPLETE_INLINE_CHIP_TAG.test(inner) ? inner : match
})
.replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
const parts: string[] = []
let start = 0
for (const index of removedDelimiters) {
parts.push(content.slice(start, index))
start = index + 1
}
parts.push(content.slice(start))
return parts.join('').replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCaptureEvent, modeState } = vi.hoisted(() => ({
mockCaptureEvent: vi.fn(),
/** The URL `mode` param as the nuqs mock serves it; `set` is the live setter once mounted. */
modeState: { initial: 'build', set: (_next: string) => {} },
}))

vi.mock('nuqs', async () => {
vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode', async () => {
const { useState } = await import('react')
return {
useQueryState: () => {
useMothershipMode: () => {
const [mode, setMode] = useState(modeState.initial)
modeState.set = setMode
return [mode, setMode]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,19 @@
* @vitest-environment jsdom
*/
import { act } from 'react'
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCaptureEvent, mockSetSearchQuery, mockSetSearchFilters, modeState } = vi.hoisted(
() => ({
mockCaptureEvent: vi.fn(),
mockSetSearchQuery: vi.fn(),
mockSetSearchFilters: vi.fn(),
/** The URL `mode` param as the nuqs mock serves it; `set` is the live setter once mounted. */
modeState: { initial: 'build', set: (_next: string) => {} },
})
)
const { mockCaptureEvent, mockLeaveSearch } = vi.hoisted(() => ({
mockCaptureEvent: vi.fn(),
mockLeaveSearch: vi.fn(),
}))
const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()

vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1' }),
}))
vi.mock('nuqs', async () => {
const { useState } = await import('react')
return {
useQueryState: (key: string) => {
const [mode, setMode] = useState(modeState.initial)
if (key !== 'mode') return [null, mockSetSearchQuery]
modeState.set = setMode
return [mode, setMode]
},
useQueryStates: () => [{}, mockSetSearchFilters],
}
})
vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent }))

Expand All @@ -38,12 +23,18 @@ import { ModeSwitcher } from '@/app/workspace/[workspaceId]/home/components/user
let root: Root | null = null
let container: HTMLDivElement | null = null

function mount() {
function mount(searchParams = '') {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root?.render(<ModeSwitcher />))
act(() =>
root?.render(
<NuqsTestingAdapter hasMemory searchParams={searchParams} onUrlUpdate={mockUrlUpdate}>
<ModeSwitcher onLeaveSearch={mockLeaveSearch} />
</NuqsTestingAdapter>
)
)
}

function trigger(): HTMLButtonElement {
Expand All @@ -63,24 +54,26 @@ function items(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
}

function select(index: number) {
act(() => {
async function select(index: number) {
await act(async () => {
items()[index].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
await vi.advanceTimersByTimeAsync(1)
})
}

beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
mockCaptureEvent.mockClear()
mockSetSearchQuery.mockClear()
mockSetSearchFilters.mockClear()
modeState.initial = 'build'
mockLeaveSearch.mockClear()
mockUrlUpdate.mockClear()
})

afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
root = null
container = null
vi.useRealTimers()
})

describe('ModeSwitcher', () => {
Expand Down Expand Up @@ -108,47 +101,52 @@ describe('ModeSwitcher', () => {
expect(rows[2].querySelector('svg')).toBeNull()
})

it('writes the chosen mode to the URL and reports the change', () => {
it('writes the chosen mode to the URL and reports the change', async () => {
mount()
openMenu()
select(1)
await select(1)

expect(trigger().textContent).toBe('Search')
expect(mockCaptureEvent).toHaveBeenCalledWith(null, 'chat_mode_changed', {
workspace_id: 'workspace-1',
mode: 'search',
})
expect(mockSetSearchQuery).not.toHaveBeenCalled()
expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('search')
expect(mockLeaveSearch).not.toHaveBeenCalled()
})

it('reads the mode from the URL on mount', () => {
modeState.initial = 'assistant'
mount()
mount('?mode=assistant')

expect(trigger().textContent).toBe('Assistant')
expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant')
})

it('drops the search query from the URL when leaving Search', () => {
modeState.initial = 'search'
mount()
it('clears the composer and search parameters together when leaving Search', async () => {
mount('?mode=search&q=budget&source=upload&updated=7d&resource=report')
openMenu()
select(0)
await select(0)

expect(trigger().textContent).toBe('Build')
expect(mockSetSearchQuery).toHaveBeenCalledWith(null, { history: 'replace', scroll: false })
expect(mockSetSearchFilters).toHaveBeenCalledWith(
{ source: null, updated: null },
{ history: 'replace', scroll: false }
expect(mockLeaveSearch).toHaveBeenCalledOnce()
expect(mockUrlUpdate).toHaveBeenCalledOnce()
expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe('resource=report')
expect(mockUrlUpdate.mock.lastCall?.[0].options).toMatchObject({
history: 'replace',
scroll: false,
})
expect(mockLeaveSearch.mock.invocationCallOrder[0]).toBeLessThan(
mockUrlUpdate.mock.invocationCallOrder[0]
)
})

it('does not report re-selecting the active mode', () => {
it('does not report re-selecting the active mode', async () => {
mount()
openMenu()
select(0)
await select(0)

expect(trigger().textContent).toBe('Build')
expect(mockCaptureEvent).not.toHaveBeenCalled()
expect(mockLeaveSearch).not.toHaveBeenCalled()
})
})
Loading
Loading