Skip to content

Commit 3c319f0

Browse files
committed
fix(search): harden source parsing, availability, and provider matching
- validate <source> urls by parsing them and requiring a host, so a malformed value never renders a dead citation link - gate Search-mode suggestions on deployment OAuth availability, sharing the predicate with the Search catalog - match connected credentials across a service's additional provider ids (Salesforce sandbox) and count them in connector telemetry - collect footer sources from the rendered text segments, covering a block-less message's fallback text and excluding subagent lanes - use a distinctive citation-link sentinel and an absolute import for SuggestedActions - teach the email tokens transcription test the composed chip geometry
1 parent 2604f08 commit 3c319f0

16 files changed

Lines changed: 249 additions & 101 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,19 @@ function nextInlineSegmentLabel(segment?: ContentSegment): string {
128128

129129
/**
130130
* The `<source>` payloads of the segment being rendered, in emission order. An
131-
* inline citation is written into the markdown as `[label](#src-N)` so it flows
132-
* with its paragraph, and the link renderer resolves `N` back through this
133-
* context — the component map is static, so it is the one channel from segment
134-
* data into it.
131+
* inline citation is written into the markdown as a link to a sentinel
132+
* fragment carrying the payload's index, so it flows with its paragraph, and
133+
* the link renderer resolves the index back through this context — the
134+
* component map is static, so it is the one channel from segment data into it.
135135
*/
136136
const SourceRefsContext = createContext<readonly SourceTagData[]>([])
137137

138-
const SOURCE_LINK_PREFIX = '#src-'
138+
/**
139+
* Fragment prefix of a generated citation link. Internal — never navigated —
140+
* and deliberately not a name the model would write on its own; an index that
141+
* resolves to no parsed source falls back to the link text.
142+
*/
143+
const SOURCE_LINK_PREFIX = '#sim-source-ref-'
139144

140145
interface SourceReferenceProps {
141146
index: number

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,12 +1407,14 @@ describe('source tag', () => {
14071407
})
14081408

14091409
it('rejects a source without an absolute http(s) url', () => {
1410-
const { segments } = parseSpecialTags(
1411-
'See <source>{"url":"docs/internal.md","siteName":"Docs"}</source>.',
1412-
false
1413-
)
1410+
for (const url of ['docs/internal.md', 'https://?', 'ftp://host/x', 'https://a b.example/x']) {
1411+
const { segments } = parseSpecialTags(
1412+
`See <source>{"url":"${url}","siteName":"Docs"}</source>.`,
1413+
false
1414+
)
14141415

1415-
expect(segments.some((segment) => segment.type === 'source')).toBe(false)
1416+
expect(segments.some((segment) => segment.type === 'source')).toBe(false)
1417+
}
14161418
})
14171419

14181420
it('hides a half-arrived source opener while streaming', () => {

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,9 +556,19 @@ function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagDa
556556
)
557557
}
558558

559-
/** Only an absolute http(s) URL can be linked; anything else is not a source. */
559+
/**
560+
* Only an absolute http(s) URL with a host can be linked; anything else is not
561+
* a source. Parsed rather than pattern-matched so a malformed value such as
562+
* `https://?` — which a prefix check would accept — never becomes a dead link.
563+
*/
560564
function isHttpUrl(value: unknown): value is string {
561-
return typeof value === 'string' && /^https?:\/\/\S+$/i.test(value.trim())
565+
if (typeof value !== 'string' || /\s/.test(value)) return false
566+
try {
567+
const url = new URL(value)
568+
return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname.length > 0
569+
} catch {
570+
return false
571+
}
562572
}
563573

564574
function isSourceTagData(value: unknown): value is SourceTagData {

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -864,7 +864,6 @@ function MessageContentInner({
864864
() => (blocks.length > 0 ? parseBlocks(blocks) : []),
865865
[blocks, blockOverlayVersion]
866866
)
867-
const sources = useMemo(() => collectMessageSources(blocks), [blocks])
868867

869868
const [trailingRevealing, setTrailingRevealing] = useState(false)
870869
const handleTrailingRevealChange = useCallback((revealing: boolean) => {
@@ -880,12 +879,28 @@ function MessageContentInner({
880879
}, [])
881880
const [isStreamIdle, setIsStreamIdle] = useState(false)
882881

883-
const segments: MessageSegment[] =
884-
parsed.length > 0
885-
? parsed
886-
: fallbackContent?.trim()
887-
? [{ type: 'text' as const, id: 'text-fallback', content: fallbackContent }]
888-
: []
882+
const segments = useMemo<MessageSegment[]>(
883+
() =>
884+
parsed.length > 0
885+
? parsed
886+
: fallbackContent?.trim()
887+
? [{ type: 'text', id: 'text-fallback', content: fallbackContent }]
888+
: [],
889+
[parsed, fallbackContent]
890+
)
891+
/**
892+
* Collected from the segments that render, not the raw blocks: that is the
893+
* same text the inline chips come from, so the footer agrees with them — it
894+
* covers the fallback text of a block-less message and leaves out lane text
895+
* that `parseBlocks` folds into agent groups.
896+
*/
897+
const sources = useMemo(
898+
() =>
899+
collectMessageSources(
900+
segments.flatMap((segment) => (segment.type === 'text' ? [segment.content] : []))
901+
),
902+
[segments]
903+
)
889904
const visibleStreamActivityKey = getVisibleStreamActivityKey(segments)
890905

891906
// Every visible stream update restarts the quiet-period clock. A layout

apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { ContentBlockType } from '@/app/workspace/[workspaceId]/home/types'
65
import { collectMessageSources, deriveMessagePhase, resolveToolDisplayState } from './utils'
76

87
describe('deriveMessagePhase', () => {
@@ -41,33 +40,21 @@ describe('resolveToolDisplayState', () => {
4140
describe('collectMessageSources', () => {
4241
const source = (url: string, extra = '') => `<source>{"url":"${url}"${extra}}</source>`
4342

44-
it('collects every distinct source across the message text, in first-cited order', () => {
45-
const blocks = [
46-
{
47-
type: ContentBlockType.text,
48-
content: `First point. ${source('https://a.example/1', ',"siteName":"A"')} Second. ${source('https://b.example/2')}`,
49-
},
50-
{ type: ContentBlockType.tool_call },
51-
{
52-
type: ContentBlockType.text,
53-
content: `Again. ${source('https://a.example/1')} New. ${source('https://c.example/3')}`,
54-
},
43+
it('collects every distinct source across the given text, in first-cited order', () => {
44+
const texts = [
45+
`First point. ${source('https://a.example/1', ',"siteName":"A"')} Second. ${source('https://b.example/2')}`,
46+
`Again. ${source('https://a.example/1')} New. ${source('https://c.example/3')}`,
5547
]
5648

57-
expect(collectMessageSources(blocks).map((entry) => entry.url)).toEqual([
49+
expect(collectMessageSources(texts).map((entry) => entry.url)).toEqual([
5850
'https://a.example/1',
5951
'https://b.example/2',
6052
'https://c.example/3',
6153
])
62-
expect(collectMessageSources(blocks)[0].siteName).toBe('A')
54+
expect(collectMessageSources(texts)[0].siteName).toBe('A')
6355
})
6456

65-
it('ignores subagent lanes and text without sources', () => {
66-
const blocks = [
67-
{ type: ContentBlockType.subagent_text, content: source('https://lane.example/x') },
68-
{ type: ContentBlockType.text, content: 'Plain prose.' },
69-
]
70-
71-
expect(collectMessageSources(blocks)).toEqual([])
57+
it('returns nothing for prose without sources', () => {
58+
expect(collectMessageSources(['Plain prose.', ''])).toEqual([])
7259
})
7360
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,19 @@ import {
2525
parseSpecialTags,
2626
type SourceTagData,
2727
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
28-
import {
29-
type ContentBlock,
30-
ContentBlockType,
31-
type ToolCallStatus,
32-
} from '@/app/workspace/[workspaceId]/home/types'
28+
import type { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
3329

3430
export type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
3531

3632
/**
37-
* Every distinct `<source>` cited in the message's own prose, in first-cited
38-
* order, for the footer strip. Only main-lane text counts: subagent lanes fold
39-
* into agent groups rather than the answer, and a tool's output is not a
40-
* citation.
33+
* Every distinct `<source>` cited across the given prose, in first-cited order,
34+
* for the footer strip. Callers pass the text segments the message actually
35+
* renders as its answer.
4136
*/
42-
export function collectMessageSources(blocks: ContentBlock[]): SourceTagData[] {
37+
export function collectMessageSources(texts: readonly string[]): SourceTagData[] {
4338
const byUrl = new Map<string, SourceTagData>()
44-
for (const block of blocks) {
45-
if (block.type !== ContentBlockType.text || !block.content) continue
46-
for (const segment of parseSpecialTags(block.content, false).segments) {
39+
for (const text of texts) {
40+
for (const segment of parseSpecialTags(text, false).segments) {
4741
if (segment.type === 'source' && !byUrl.has(segment.data.url)) {
4842
byUrl.set(segment.data.url, segment.data)
4943
}

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.test.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@ vi.mock('@/lib/sim-search/connectors', () => {
1515
type,
1616
meta: { id: type, name, description: `Sync ${name}`, icon },
1717
providerId,
18+
providerIds: [providerId],
1819
requiredScopes: [],
1920
serviceName: name,
2021
serviceIcon: icon,
2122
blockType: type,
2223
})
2324
return {
25+
isSearchConnectorConnected: (
26+
candidate: { providerIds: string[] },
27+
connected: ReadonlySet<string>
28+
) => candidate.providerIds.some((providerId) => connected.has(providerId)),
2429
SEARCH_CONNECTORS: [
2530
connector('airtable', 'Airtable', 'airtable'),
2631
connector('confluence', 'Confluence', 'confluence'),
@@ -34,14 +39,16 @@ vi.mock('@/lib/sim-search/connectors', () => {
3439

3540
import { computeConnectorActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions'
3641

42+
const ALL_AVAILABLE = () => true
43+
3744
describe('computeConnectorActions', () => {
3845
beforeEach(() => {
3946
/** A zero roll always samples the first remaining candidate, so the rotation is catalog order. */
4047
mockRandomFloat.mockReturnValue(0)
4148
})
4249

4350
it('pins Confluence, Jira, and JSM first and fills the last slot from the rotation', () => {
44-
const actions = computeConnectorActions(new Set())
51+
const actions = computeConnectorActions(new Set(), ALL_AVAILABLE)
4552

4653
expect(actions.map((action) => action.id)).toEqual([
4754
'connect-confluence',
@@ -57,17 +64,31 @@ describe('computeConnectorActions', () => {
5764
})
5865

5966
it('drops every connector on a connected provider and refills from the rotation', () => {
60-
const actions = computeConnectorActions(new Set(['jira', 'airtable']))
67+
const actions = computeConnectorActions(new Set(['jira', 'airtable']), ALL_AVAILABLE)
68+
69+
expect(actions.map((action) => action.id)).toEqual([
70+
'connect-confluence',
71+
'connect-notion',
72+
'connect-slack',
73+
])
74+
})
75+
76+
it('drops connectors this deployment cannot connect, pinned or not', () => {
77+
const actions = computeConnectorActions(
78+
new Set(),
79+
(connector) => connector.type !== 'jira' && connector.type !== 'airtable'
80+
)
6181

6282
expect(actions.map((action) => action.id)).toEqual([
6383
'connect-confluence',
84+
'connect-jsm',
6485
'connect-notion',
6586
'connect-slack',
6687
])
6788
})
6889

6990
it('returns fewer than four rows once the rotation is exhausted', () => {
70-
const actions = computeConnectorActions(new Set(['airtable', 'notion', 'slack']))
91+
const actions = computeConnectorActions(new Set(['airtable', 'notion', 'slack']), ALL_AVAILABLE)
7192

7293
expect(actions.map((action) => action.id)).toEqual([
7394
'connect-confluence',

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { SEARCH_CONNECTORS, type SearchConnector } from '@/lib/sim-search/connectors'
1+
import {
2+
isSearchConnectorConnected,
3+
SEARCH_CONNECTORS,
4+
type SearchConnector,
5+
} from '@/lib/sim-search/connectors'
26
import type { Action } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/types'
37
import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample'
48

@@ -39,12 +43,17 @@ function toConnectorAction(connector: SearchConnector): Action {
3943
* sample of the rest to fill four slots. A connector whose provider the viewer
4044
* has already connected is dropped from both halves — so Jira and Jira Service
4145
* Management, which share one provider, leave together — and a pinned slot
42-
* freed that way is taken by the rotation.
46+
* freed that way is taken by the rotation. Connectors this deployment cannot
47+
* connect are dropped the same way, so a row never opens a modal that fails.
4348
*/
44-
export function computeConnectorActions(connectedProviderIds: ReadonlySet<string>): Action[] {
45-
const isConnected = (connector: SearchConnector) => connectedProviderIds.has(connector.providerId)
46-
const pinned = PINNED.filter((connector) => !isConnected(connector))
47-
const pool = ROTATING.filter((connector) => !isConnected(connector))
49+
export function computeConnectorActions(
50+
connectedProviderIds: ReadonlySet<string>,
51+
isAvailable: (connector: SearchConnector) => boolean
52+
): Action[] {
53+
const offered = (connector: SearchConnector) =>
54+
isAvailable(connector) && !isSearchConnectorConnected(connector, connectedProviderIds)
55+
const pinned = PINNED.filter(offered)
56+
const pool = ROTATING.filter(offered)
4857
const rotating = weightedSample(pool, CONNECTOR_ACTION_COUNT - pinned.length, () => 1)
4958
return [...pinned, ...rotating].map(toConnectorAction)
5059
}

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ vi.mock('@/hooks/queries/kb/knowledge', () => ({
3232
vi.mock('@/app/workspace/[workspaceId]/search/hooks/use-search-credentials', () => ({
3333
useSearchCredentials: mockUseSearchCredentials,
3434
}))
35+
vi.mock('@/hooks/use-permission-config', () => ({
36+
usePermissionConfig: () => ({
37+
integrationAvailability: new Map([['notion', { state: 'unavailable', oauthAvailable: false }]]),
38+
}),
39+
}))
3540

3641
/** The Build-mode pool is built from the block catalog at module load; an empty catalog keeps it to the table starters. */
3742
vi.mock('@/blocks/registry', () => ({ getAllBlockMeta: () => ({}), getAllBlocks: () => [] }))
@@ -42,18 +47,28 @@ vi.mock('@/lib/sim-search/connectors', () => {
4247
type,
4348
meta: { id: type, name, description: `Sync ${name}`, icon },
4449
providerId,
50+
providerIds: [providerId],
4551
requiredScopes: ['read'],
4652
serviceName: name,
4753
serviceIcon: icon,
4854
blockType: type,
4955
})
5056
return {
57+
isSearchConnectorConnected: (
58+
candidate: { providerIds: string[] },
59+
connected: ReadonlySet<string>
60+
) => candidate.providerIds.some((providerId) => connected.has(providerId)),
61+
isSearchConnectorAvailable: (
62+
candidate: { blockType: string },
63+
availability: ReadonlyMap<string, { oauthAvailable: boolean }>
64+
) => availability.get(candidate.blockType)?.oauthAvailable ?? true,
5165
SEARCH_CONNECTORS: [
5266
connector('airtable', 'Airtable', 'airtable'),
5367
connector('confluence', 'Confluence', 'confluence'),
5468
connector('jira', 'Jira', 'jira'),
5569
connector('jsm', 'Jira Service Management', 'jira'),
5670
connector('notion', 'Notion', 'notion'),
71+
connector('slack', 'Slack', 'slack'),
5772
],
5873
}
5974
})
@@ -117,7 +132,7 @@ describe('SuggestedActions', () => {
117132
expect(rows().map((row) => row.textContent)).toContain('Integrate with Slack')
118133
})
119134

120-
it('swaps to the connector list in Search mode, minus providers already connected', () => {
135+
it('swaps to the connector list in Search mode, minus connected and unavailable connectors', () => {
121136
mount()
122137

123138
act(() => useMothershipModeStore.getState().setMode('search'))
@@ -126,7 +141,7 @@ describe('SuggestedActions', () => {
126141
expect(rows().map((row) => row.textContent)).toEqual([
127142
'Connect Confluence',
128143
'Connect Airtable',
129-
'Connect Notion',
144+
'Connect Slack',
130145
])
131146
})
132147

@@ -144,7 +159,12 @@ describe('SuggestedActions', () => {
144159
expect(mockCaptureEvent).toHaveBeenCalledWith(
145160
null,
146161
'suggested_action_clicked',
147-
expect.objectContaining({ kind: 'connector', action_id: 'connect-confluence', position: 0 })
162+
expect.objectContaining({
163+
kind: 'connector',
164+
action_id: 'connect-confluence',
165+
position: 0,
166+
connected_provider_count: 1,
167+
})
148168
)
149169
})
150170
})

0 commit comments

Comments
 (0)