From 3d47bf8caa5cf62f1539f298e90fccdb97b77b23 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 24 Aug 2026 16:39:34 +0200 Subject: [PATCH] fix: match integration suggestions on slug words instead of substrings Co-Authored-By: Claude Opus 5 --- .../components/copilot/chat/shared.test.ts | 69 +++++++++++++++++++ .../src/lib/components/copilot/chat/shared.ts | 32 +++++++-- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index ddda2c7d76..84c2d2f50f 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -1453,6 +1453,75 @@ describe('createSearchHubScriptsTool', () => { expect(JSON.parse(raw)).toEqual({ results: [], suggested_integrations: ['stripe'] }) }) + + // Matching a query word anywhere inside a slug turns every short English word into + // a hit — `for` in salesforce, `the` in basis_theory — so a task that names no + // integration came back with a confident-looking list of them. + it('suggests nothing for a request that names no integration', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [ + { name: 'salesforce' }, + { name: 'basis_theory' }, + { name: 'hackernews' }, + { name: 's3' } + ]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'list all the invoices for the new month' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).suggested_integrations).toEqual([]) + }) + + // Google's integrations are all a compressed `g` plus the product word, so the + // word a user actually says starts one character into the slug. + it('reaches an integration whose slug compresses the vendor name', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [ + { name: 'gsheets' }, + { name: 'gdrive' }, + { name: 'smartsheet' } + ]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'add a row to a google sheet' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).suggested_integrations).toEqual(['gsheets']) + }) + + // A slug shorter than the token floor is only reachable by an exact match. + it('still reaches a two-character integration slug', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [{ name: 's3' }, { name: 'salesforce' }]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'upload a file to s3' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).suggested_integrations).toEqual(['s3']) + }) }) describe('getHubIntegrationTool', () => { diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index c2d1c24549..944b5c329a 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -1271,15 +1271,37 @@ async function suggestHubIntegrations(query: string): Promise { const tokens = query .toLowerCase() .split(/[^a-z0-9]+/) - .filter((t) => t.length > 2) + .filter((t) => t.length >= 2) return hubIntegrationsCache - .filter((name) => { - const slug = name.toLowerCase() - return tokens.some((t) => slug.includes(t) || (slug.length > 2 && t.includes(slug))) - }) + .filter((name) => tokens.some((t) => tokenMatchesSlug(t, name.toLowerCase()))) .slice(0, MAX_SUGGESTED_INTEGRATIONS) } +/** Matches a query word against a slug on word boundaries rather than by bare + * substring. A substring test reads every three-letter English word as a hit — + * `for` in sales*for*ce, `the` in basis_*the*ory — so a request that names no + * integration still came back with five confident-looking ones. Short tokens must + * equal a slug or one of its parts, which is also what reaches the two-character + * slugs (`s3`, `wiz`) that a length filter alone hides. */ +function tokenMatchesSlug(token: string, slug: string): boolean { + const parts = slug.split(/[_-]/).filter(Boolean) + if (slug === token || parts.includes(token)) { + return true + } + if (token.length < 4) { + return false + } + const extends_ = (a: string, b: string) => a.startsWith(b) || b.startsWith(a) + return ( + parts.some((p) => p.length >= 4 && extends_(p, token)) || + (slug.length >= 4 && extends_(slug, token)) || + // A whole family of slugs compresses the vendor to one letter in front of the + // product word, so the word the user actually says starts one character in: + // "google sheet" has to reach `gsheets`, "google drive" `gdrive`. + parts.some((p) => p.length >= 5 && extends_(p.slice(1), token)) + ) +} + export const clearHubIntegrationsCache = () => { hubIntegrationsCache = undefined }