Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
edfe320
feat(copilot): replace search_documentation with path-scoped search_d…
j15z Jul 24, 2026
f5538e9
improvement(copilot): label docs corpus reads as Section/filename in …
j15z Jul 24, 2026
9b5ead2
improvement(copilot): show the query in search_docs tool chips
j15z Jul 24, 2026
bb0d3ab
improvement(copilot): log chat request validation failures server-side
j15z Jul 24, 2026
db3ac9e
fix(copilot): stop empty-id resource chips from bricking a chat
j15z Jul 24, 2026
611d3c9
feat(copilot): build the docs vfs from a generated manifest, rescope …
j15z Jul 24, 2026
b8409d5
fix(review): act on docs-vfs review findings
j15z Jul 24, 2026
b8d12bb
fix(copilot): explain a short or empty search_docs result set
j15z Jul 25, 2026
06f3a37
fix(copilot): make the search_documentation alias actually reachable
j15z Jul 25, 2026
e52205d
fix(copilot): include a section overview in either layout when scopin…
j15z Jul 25, 2026
6a1a859
fix(copilot): make the search_docs topK clamp type-safe and test it
j15z Jul 25, 2026
01c4d70
fix(copilot): restore the query in search_docs tool chips
j15z Jul 25, 2026
d07eaf5
improvement(copilot): share the unmounted-docs list, shrink the searc…
j15z Jul 28, 2026
512fec7
chore(copilot): regenerate the tool catalog for the retired quick-ref…
j15z Jul 28, 2026
7be0522
fix(review): attach the scope-error TSDoc to the class it documents
j15z Jul 29, 2026
1ed1929
fix(review): hide the retired get_platform_actions chip like search_d…
j15z Jul 29, 2026
054d243
fix(review): harden docs corpus edges — trailing-slash glob, root-ind…
j15z Jul 29, 2026
4eead00
chore(copilot): regenerate the tool catalog for the lean search agent
j15z Jul 29, 2026
ac8eecc
Merge origin/staging into feat/enhance-search-agent
j15z Jul 29, 2026
a30ad25
improvement(copilot): retire search_documentation and get_platform_ac…
j15z Jul 29, 2026
bd25ff7
docs(copilot): mark the inert @Docs plumbing as the seam for a search…
j15z Jul 29, 2026
3b0c9fd
changed search_docs tool title to Searching Sim docs
j15z Jul 29, 2026
6d75617
fix(copilot): apply the Searching Sim docs rename to the dynamic titl…
j15z Jul 29, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ jobs:
- name: Verify agent stream capability docs are in sync
run: bun run agent-stream-docs:check

- name: Verify docs manifest is in sync
run: bun run docs-manifest:check

- name: Migration safety (zero-downtime) audit
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1353,7 +1353,7 @@ export function WorkspaceResourceDisplay({

return {
type: toMothershipResourceType(data.type),
id: data.id ?? fileFromPath?.id ?? data.path ?? '',
id: [data.id, fileFromPath?.id, data.path].find((value) => value?.trim()) ?? '',
title,
...(data.type === 'file' && data.path ? { path: data.path } : {}),
}
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/copilot/chat/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ const ChatContextSchema = z.object({
'logs',
'workflow_block',
'knowledge',
// Inert today: processContextsServer drops docs contexts. Kept in the
// schema so a future @Docs mention can resolve through search_docs.
'docs',
'table',
'file',
Expand Down Expand Up @@ -1296,6 +1298,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
otelRoot?.finish('error', error)

if (isZodError(error)) {
logger.warn(`[${requestId}] Chat request failed validation`, { issues: error.issues })
return validationErrorResponse(error, 'Invalid request data')
}

Expand Down
17 changes: 17 additions & 0 deletions apps/sim/lib/copilot/chat/process-contents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ describe('processContextsServer - skill contexts', () => {
})
})

describe('processContextsServer - docs contexts', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('resolves a tagged docs context to nothing while @docs tagging is disabled', async () => {
const result = await processContextsServer(
[{ kind: 'docs', label: 'Docs' } as ChatContext],
'user-1',
'how do loops work @Docs',
'ws-1'
)

expect(result).toEqual([])
})
})

describe('processContextsServer - MCP contexts', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
72 changes: 7 additions & 65 deletions apps/sim/lib/copilot/chat/process-contents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import { getSkillById } from '@/lib/workflows/skills/operations'
import { listFolders } from '@/lib/workflows/utils'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { escapeRegExp } from '@/executor/constants'
import type { ChatContext } from '@/stores/panel'

type AgentContextType =
Expand All @@ -43,7 +42,6 @@ type AgentContextType =
| 'table'
| 'file'
| 'workflow_block'
| 'docs'
| 'folder'
| 'filefolder'
| 'active_resource'
Expand All @@ -69,7 +67,8 @@ const logger = createLogger('ProcessContents')
export async function processContextsServer(
contexts: ChatContext[] | undefined,
userId: string,
userMessage?: string,
/** Retained for call-site compatibility; unused while @docs tagging is disabled. */
_userMessage: string | undefined,
currentWorkspaceId?: string,
chatId?: string
): Promise<AgentContext[]> {
Expand Down Expand Up @@ -220,21 +219,11 @@ export async function processContextsServer(
path: result.path,
}
}
if (ctx.kind === 'docs') {
try {
const { searchDocumentationServerTool } = await import(
'@/lib/copilot/tools/server/docs/search-documentation'
)
const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation'
const query = sanitizeMessageForDocs(rawQuery, contexts)
const res = await searchDocumentationServerTool.execute({ query, topK: 10 })
const content = JSON.stringify(res?.results || [])
return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content }
} catch (e) {
logger.error('Failed to process docs context', e)
return null
}
}
// `docs` contexts are intentionally inert: @docs tagging is disabled now
// that the docs corpus lives in the `docs/` VFS tree. If @Docs returns,
// resolve it here through searchDocs (the search_docs backend) rather
// than reviving the retired whole-message pre-search. A tagged context
// resolves to nothing and is filtered out below.
return null
} catch (error) {
logger.error('Failed processing context (server)', { ctx, error })
Expand All @@ -256,53 +245,6 @@ export async function processContextsServer(
return filtered
}

function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | undefined): string {
if (!rawMessage) return ''
if (!Array.isArray(contexts) || contexts.length === 0) {
// No context mapping; conservatively strip all @mentions-like tokens
const stripped = rawMessage
.replace(/(^|\s)@([^\s]+)/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
return stripped
}

// Gather labels by kind
const blockLabels = new Set(
contexts
.filter((c) => c.kind === 'blocks')
.map((c) => c.label)
.filter((l): l is string => typeof l === 'string' && l.length > 0)
)
const nonBlockLabels = new Set(
contexts
.filter((c) => c.kind !== 'blocks')
.map((c) => c.label)
.filter((l): l is string => typeof l === 'string' && l.length > 0)
)

let result = rawMessage

// 1) Remove all non-block mentions entirely
for (const label of nonBlockLabels) {
const pattern = new RegExp(`(^|\\s)@${escapeRegExp(label)}(?!\\S)`, 'g')
result = result.replace(pattern, ' ')
}

// 2) For block mentions, strip the '@' but keep the block name
for (const label of blockLabels) {
const pattern = new RegExp(`@${escapeRegExp(label)}(?!\\S)`, 'g')
result = result.replace(pattern, label)
}

// 3) Remove any remaining @mentions (unknown or not in contexts)
result = result.replace(/(^|\s)@([^\s]+)/g, ' ')

// Normalize whitespace
result = result.replace(/\s{2,}/g, ' ').trim()
return result
}

async function processSkillFromDb(
skillId: string,
workspaceId: string,
Expand Down
158 changes: 158 additions & 0 deletions apps/sim/lib/copilot/docs/docs-corpus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
couldMatchDocsScope,
DocsCorpusError,
globDocs,
grepDocsPage,
isDocsPath,
readDocsPage,
} from '@/lib/copilot/docs/docs-corpus'
import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest'

const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx')

describe('docs corpus scoping', () => {
it('recognizes docs paths', () => {
expect(isDocsPath('docs/workflows.mdx')).toBe(true)
expect(isDocsPath('docs')).toBe(true)
expect(isDocsPath('/docs/workflows.mdx')).toBe(true)
expect(isDocsPath('workflows.mdx')).toBe(false)
expect(isDocsPath('files/report.pdf')).toBe(false)
expect(isDocsPath('docsomething/x')).toBe(false)
expect(isDocsPath(undefined)).toBe(false)
})

it('is opt-in: only an explicit docs/ pattern can match', () => {
expect(couldMatchDocsScope('docs/**')).toBe(true)
expect(couldMatchDocsScope('docs/workflows/**')).toBe(true)
expect(couldMatchDocsScope('**')).toBe(false)
expect(couldMatchDocsScope('**/*.mdx')).toBe(false)
expect(couldMatchDocsScope('*')).toBe(false)
expect(couldMatchDocsScope(undefined)).toBe(false)
})
})

describe('globDocs', () => {
it('lists the whole corpus under docs/**', () => {
const files = globDocs('docs/**')
expect(files.length).toBeGreaterThan(DOCS_MANIFEST.length)
expect(files).toContain('docs/workflows/blocks/agent.mdx')
expect(files).toContain('docs/workflows/blocks')
})

it('scopes to a section', () => {
const files = globDocs('docs/integrations/*.mdx')
expect(files).toContain('docs/integrations/gmail.mdx')
expect(files.every((path) => path.startsWith('docs/integrations/'))).toBe(true)
})

it('excludes academy and api-reference', () => {
expect(globDocs('docs/academy/**')).toEqual([])
expect(globDocs('docs/api-reference/**')).toEqual([])
})

it('maps section index pages onto their parent URL path', () => {
expect(globDocs('docs/workflows.mdx')).toEqual(['docs/workflows.mdx'])
expect(globDocs('docs/workflows/index.mdx')).toEqual([])
})

it('treats a trailing-slash pattern like the bare directory instead of matching nothing', () => {
expect(globDocs('docs/')).toEqual(['docs'])
expect(globDocs('docs/integrations/')).toEqual(['docs/integrations'])
})
})

describe('readDocsPage', () => {
const fetchMock = vi.fn()

beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})

afterEach(() => {
vi.unstubAllGlobals()
})

it('fetches the manifest path verbatim from the docs site', async () => {
expect(SAMPLE_PAGE).toBeDefined()
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '# Agent\n\nbody' })

const page = await readDocsPage(`docs/${SAMPLE_PAGE}`)

expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock.mock.calls[0][0]).toBe(`https://docs.sim.ai/${SAMPLE_PAGE}`)
expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 })
})

it('rejects an unknown page without fetching', async () => {
await expect(readDocsPage('docs/not-a-real-page.mdx')).rejects.toThrow(DocsCorpusError)
expect(fetchMock).not.toHaveBeenCalled()
})

it('points a directory read at glob', async () => {
await expect(readDocsPage('docs/workflows/blocks')).rejects.toThrow(/is a directory/)
expect(fetchMock).not.toHaveBeenCalled()
})

it('surfaces a docs-site outage as a retryable error', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 502, text: async () => '' })
await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/)
})

it('treats a network failure as retryable', async () => {
fetchMock.mockRejectedValue(new Error('socket hang up'))
await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/)
})

it('reports a page the site no longer serves as permanent, not retryable', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404, text: async () => '' })
const error = await readDocsPage(`docs/${SAMPLE_PAGE}`).catch((e) => e)
expect(error).toBeInstanceOf(DocsCorpusError)
expect(error.message).toMatch(/does not serve it/)
expect(error.message).toMatch(/retrying will not help/)
expect(error.message).not.toMatch(/temporarily unavailable/)
})

it('still treats 429 as retryable rather than permanent', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 429, text: async () => '' })
await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/)
})
})

describe('grepDocsPage', () => {
const fetchMock = vi.fn()

beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})

afterEach(() => {
vi.unstubAllGlobals()
})

it('greps exactly one page', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
text: async () => 'intro line\nsystemPrompt matters\ntail',
})

const matches = await grepDocsPage(`docs/${SAMPLE_PAGE}`, 'systemPrompt')

expect(fetchMock).toHaveBeenCalledOnce()
expect(matches).toEqual([
{ path: `docs/${SAMPLE_PAGE}`, line: 2, content: 'systemPrompt matters' },
])
})

it('refuses a multi-page scope so one grep is never hundreds of fetches', async () => {
await expect(grepDocsPage('docs/', 'cron')).rejects.toThrow(/single page/)
await expect(grepDocsPage('docs/workflows', 'cron')).rejects.toThrow(/single page/)
expect(fetchMock).not.toHaveBeenCalled()
})
})
Loading
Loading