Skip to content

Commit 45522d5

Browse files
committed
fix(search): strip only JSON cards from MCP answers and keep tag-shaped prose
- Remove interactive cards by their JSON payload so closing markers inside strings cannot end a card and tag-shaped prose stays - Keep the Slack stream's existing tag withholding unchanged - Require valid element syntax in looksLikeHtml so an address like <a@acme.com> stays text - Keep verified results when verification is rate-limited and name the rate limit
1 parent 0a4813b commit 45522d5

12 files changed

Lines changed: 96 additions & 45 deletions

File tree

‎apps/sim/connectors/utils.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
isIndexableConnectorFile,
7373
isSkippableMicrosoftGraphFolderError,
7474
isSkippedDocument,
75+
looksLikeHtml,
7576
MICROSOFT_GRAPH_MAX_CURSOR_ENCODED_BYTES,
7677
MICROSOFT_GRAPH_MAX_ITEM_ID_BYTES,
7778
MICROSOFT_GRAPH_MAX_PENDING_FOLDERS,
@@ -1754,3 +1755,14 @@ describe('BoundedLines', () => {
17541755
})
17551756
})
17561757
})
1758+
1759+
describe('looksLikeHtml', () => {
1760+
it('recognizes elements with valid tag syntax', () => {
1761+
for (const markup of ['<p>', '</p>', '<br>', '<br/>', '<a href="x">', '<td class="c">'])
1762+
expect(looksLikeHtml(`text ${markup} text`)).toBe(true)
1763+
})
1764+
it('does not mistake an address whose name starts like a tag for markup', () => {
1765+
for (const text of ['Invite <a@acme.com>', 'From <b.smith@acme.com>', '<i.e. later>'])
1766+
expect(looksLikeHtml(text)).toBe(false)
1767+
})
1768+
})

‎apps/sim/connectors/utils.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,11 +339,11 @@ function decodeCharacterReference(raw: string, code: number): string {
339339
* A false positive therefore does not merely pass text through untouched — it
340340
* deletes the bracketed span and flattens the document's line structure. Plain
341341
* text routinely contains angle brackets that are not markup: an email address
342-
* (`Reply from John <john@acme.com>`), a markdown autolink
342+
* (`Reply from John <john@acme.com>`, or `<a@acme.com>`, whose name is a tag's), a markdown autolink
343343
* (`<https://acme.com>`), or a placeholder (`<redacted>`).
344344
*/
345345
const HTML_TAG_PATTERN =
346-
/<\/?(?:p|div|br|hr|ul|ol|li|h[1-6]|table|thead|tbody|tr|td|th|span|strong|em|b|i|u|a|code|pre|blockquote|img|figure)\b[^>]*>/i
346+
/<\/?(?:p|div|br|hr|ul|ol|li|h[1-6]|table|thead|tbody|tr|td|th|span|strong|em|b|i|u|a|code|pre|blockquote|img|figure)(?=[\s/>])[^>]*>/i
347347

348348
/**
349349
* Reports whether a value carries real HTML markup and is therefore worth routing
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/** @vitest-environment node */
2+
import { describe, expect, it } from 'vitest'
3+
import { stripInteractiveCards } from '@/lib/knowledge/application/chat-citations'
4+
5+
describe('stripInteractiveCards', () => {
6+
it('removes a JSON card, including closing markers inside its strings', () => {
7+
expect(
8+
stripInteractiveCards('Answer <options>{"1":{"title":"Say </options> x"}}</options> end')
9+
).toBe('Answer end')
10+
})
11+
it('removes thinking blocks', () => {
12+
expect(stripInteractiveCards('<thinking>plan</thinking>Answer')).toBe('Answer')
13+
})
14+
it('keeps tag-shaped prose', () => {
15+
const prose = 'Wrap choices in <options>your list</options> tags, or use <question> to ask.'
16+
expect(stripInteractiveCards(prose)).toBe(prose)
17+
})
18+
it('drops a card left open before its JSON payload', () => {
19+
expect(stripInteractiveCards('Connect here <credential>{"type":"link"')).toBe('Connect here ')
20+
})
21+
})

‎apps/sim/lib/knowledge/application/chat-citations.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,28 @@ export interface SearchChatCitation {
88
url: string
99
}
1010

11+
/** Cards the Chat UI renders from a JSON payload; an MCP text answer drops them. */
12+
const CARD_TAGS = 'options|question|usage_upgrade|credential|workspace_resource'
13+
/** A JSON string owns its escaped quotes and any tag-shaped text inside it. */
14+
const JSON_STRING = '"(?:\\\\.|[^"\\\\\\r\\n])*"'
15+
/**
16+
* A card whose body is a JSON value, a thinking block, or a card opener left unclosed before a
17+
* JSON payload. A tag-shaped pair or opener in ordinary prose is not a card and stays.
18+
*/
19+
const INTERACTIVE_CARD = new RegExp(
20+
[
21+
`<(${CARD_TAGS})>\\s*[[{](?:${JSON_STRING}|[^"<])*?[\\]}]\\s*</\\1>`,
22+
'<thinking>[\\s\\S]*?</thinking>',
23+
`<(?:${CARD_TAGS})>\\s*[[{][\\s\\S]*$`,
24+
].join('|'),
25+
'g'
26+
)
27+
28+
/** Removes interactive Chat cards so a text-only surface receives only the answer prose. */
29+
export function stripInteractiveCards(content: string): string {
30+
return content.replace(INTERACTIVE_CARD, '')
31+
}
32+
1133
/** Resolves Assistant source tags only against successful, bounded retrieval evidence. */
1234
export function resolveSearchChatCitations(content: string, toolCalls: ToolCallSummary[]) {
1335
const evidence = new Map<string, SearchChatCitation>()

‎apps/sim/lib/knowledge/application/chat.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,8 @@ describe('organization Search Assistant chat', () => {
304304
})
305305

306306
it('drops interactive Chat tags from the MCP answer but keeps them in the transcript', async () => {
307-
const tags = '<options>{"1":"Open it"}</options><question>Which kit?</question>'
307+
const tags =
308+
'<options>{"1":"Open it"}</options><question>{"prompt":"Which </question> kit?"}</question>'
308309
mocks.lifecycle.mockResolvedValue(createResult({ content: `Violet suitcase.${tags}` }))
309310
const result = await execute()
310311
expect(result.content).toBe('Violet suitcase.')

‎apps/sim/lib/knowledge/application/chat.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/avail
1919
import {
2020
resolveSearchChatCitations,
2121
type SearchChatCitation,
22+
stripInteractiveCards,
2223
} from '@/lib/knowledge/application/chat-citations'
2324
import { organizationSearchChatOperation } from '@/lib/knowledge/application/chat-operations'
2425
import { loadCopilotSearchIntegrations } from '@/lib/mothership/application/load-search-integrations'
25-
import { stripInteractiveTags } from '@/lib/mothership/chat/interactive-tags'
2626
import { persistCopilotChatTurn } from '@/lib/mothership/chat/messages-store'
2727
import { buildCopilotRequestPayload } from '@/lib/mothership/chat/payload'
2828
import {
@@ -204,7 +204,7 @@ export const organizationSearchChat: OperationUseCase<
204204
)
205205
/** MCP clients receive text; interactive Chat cards stay only in the saved transcript. */
206206
const answer = resolveSearchChatCitations(
207-
stripInteractiveTags(assistantMessage.content, { complete: true }),
207+
stripInteractiveCards(assistantMessage.content),
208208
result.toolCalls
209209
)
210210
if (!answer.content.trim())

‎apps/sim/lib/mothership/chat/interactive-tags.test.ts‎

Lines changed: 0 additions & 19 deletions
This file was deleted.

‎apps/sim/lib/mothership/chat/interactive-tags.ts‎

Lines changed: 0 additions & 15 deletions
This file was deleted.

‎apps/sim/lib/sim-search/live/application.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,25 @@ describe('authorized live retrieval', () => {
274274
message: expect.stringContaining('could not be verified'),
275275
})
276276
})
277+
it('keeps verified results and names a rate limit hit during verification', async () => {
278+
mocks.search.mockResolvedValue({
279+
documents: [document, { ...document, id: 'other', url: 'https://docs.google.com/other' }],
280+
})
281+
mocks.service.mockResolvedValue({
282+
policy: defaultLiveSearchPolicy(),
283+
partial: false,
284+
verify: async ({ id }: { id: string }) => {
285+
if (id === 'other') throw new NativeSearchError('rate_limited', 'Later', 30)
286+
return true
287+
},
288+
})
289+
const result = await searchLiveKnowledge.execute({ principal, input })
290+
expect(result.results).toHaveLength(1)
291+
expect(result.live?.accounts[0]).toMatchObject({
292+
status: 'partial',
293+
message: expect.stringContaining('rate-limited verification'),
294+
})
295+
})
277296
it('fails the account when a grant is revoked during verification', async () => {
278297
mocks.service.mockResolvedValue({
279298
policy: defaultLiveSearchPolicy(),

‎apps/sim/lib/sim-search/live/application.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,13 +224,16 @@ async function verifyCandidates(session: LiveAccountSession, candidates: LiveCan
224224
return (await session.verify(document)) ? ('permitted' as const) : ('denied' as const)
225225
} catch (error) {
226226
if (error instanceof NativeSearchError && error.status === 'reconnect') throw error
227-
return 'unverified' as const
227+
return error instanceof NativeSearchError && error.status === 'rate_limited'
228+
? ('rate_limited' as const)
229+
: ('unverified' as const)
228230
}
229231
}
230232
)
231233
return {
232234
permitted: candidates.filter((_, index) => outcomes[index] === 'permitted'),
233-
unverified: outcomes.includes('unverified'),
235+
unverified: outcomes.some((outcome) => outcome === 'unverified' || outcome === 'rate_limited'),
236+
rateLimited: outcomes.includes('rate_limited'),
234237
}
235238
}
236239

@@ -359,7 +362,7 @@ export const searchLiveKnowledge = defineAuthorizedKnowledgeUseCase({
359362
* Local filters run first so provider verification is spent only on eligible results.
360363
* Undated results are verified too, so their exclusion is reported only when readable.
361364
*/
362-
const { permitted, unverified } = await measureSearchStage('live.verify', () =>
365+
const { permitted, unverified, rateLimited } = await measureSearchStage('live.verify', () =>
363366
verifyCandidates(
364367
session,
365368
candidates.filter(
@@ -403,7 +406,9 @@ export const searchLiveKnowledge = defineAuthorizedKnowledgeUseCase({
403406
? 'Service account verification covered a bounded subset of the configured users. Narrow the source user list for complete coverage; external Drive users can only search files also visible to the source administrator.'
404407
: undefined,
405408
unverified
406-
? 'Some results could not be verified against the source settings and were omitted.'
409+
? rateLimited
410+
? 'The provider rate-limited verification, so some results were omitted. Try again later.'
411+
: 'Some results could not be verified against the source settings and were omitted.'
407412
: undefined,
408413
undatedExcluded
409414
? 'Some results lacked date metadata and were excluded; date coverage is incomplete.'

0 commit comments

Comments
 (0)