Add twenty-exa application to internal app ci (#21882)

renamed exa to twenty-exa
add twenty-exa to ci check

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21882?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-06-22 11:36:33 +02:00
committed by GitHub
parent 6237598a30
commit cb5d64fefc
27 changed files with 530 additions and 21 deletions
@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -0,0 +1,25 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
describe('App installation', () => {
it('should find the installed Exa app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const matchingApplication = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(matchingApplication).toBeDefined();
});
});
@@ -0,0 +1,23 @@
import { defineApplication } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default-function.role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'2b7f4a2e-9c4b-4a11-b63c-2e5e7d3f5a9a';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Exa',
description:
'Structured web search powered by Exa. Surfaces entity-aware results (companies, people, research, news) to Twenty AI agents.',
logoUrl: 'public/exa-logomark.svg',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
serverVariables: {
EXA_API_KEY: {
description:
'Exa API key. Set by the server admin on this registration after installation; the value is injected into every logic function execution.',
isSecret: true,
isRequired: true,
},
},
});
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type ExaWebSearchInput } from '../types/exa-web-search-input.type';
const { exaConstructorMock, searchAndContentsMock, chargeCreditsMock } =
vi.hoisted(() => ({
exaConstructorMock: vi.fn(),
searchAndContentsMock: vi.fn(),
chargeCreditsMock: vi.fn(),
}));
vi.mock('exa-js', () => ({
default: vi.fn(function (apiKey: string) {
exaConstructorMock(apiKey);
return { searchAndContents: searchAndContentsMock };
}),
}));
vi.mock('twenty-sdk/billing', () => ({
chargeCredits: chargeCreditsMock,
}));
import exaWebSearch from '../exa-web-search';
type ExaWebSearchResult = {
success: boolean;
message: string;
result?: { title: string; url: string; snippet: string }[];
error?: string;
};
const handler = exaWebSearch.config.handler as (
parameters: ExaWebSearchInput,
) => Promise<ExaWebSearchResult>;
const API_KEY = 'exa-test-key';
describe('exa_web_search handler', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.EXA_API_KEY = API_KEY;
chargeCreditsMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
it('should return a configuration error and skip the search when EXA_API_KEY is not set', async () => {
delete process.env.EXA_API_KEY;
const result = await handler({ query: 'twenty crm' });
expect(result.success).toBe(false);
expect(result.message).toBe('Exa is not configured');
expect(result.error).toContain('EXA_API_KEY is not set');
expect(exaConstructorMock).not.toHaveBeenCalled();
expect(searchAndContentsMock).not.toHaveBeenCalled();
expect(chargeCreditsMock).not.toHaveBeenCalled();
});
it('should map Exa results to title/url/snippet and report success', async () => {
searchAndContentsMock.mockResolvedValue({
results: [
{
title: 'Twenty CRM',
url: 'https://twenty.com',
highlights: ['Open-source CRM', 'Built with modern tech'],
},
],
});
const result = await handler({ query: 'twenty crm' });
expect(exaConstructorMock).toHaveBeenCalledWith(API_KEY);
expect(result).toEqual({
success: true,
message: 'Found 1 results for "twenty crm"',
result: [
{
title: 'Twenty CRM',
url: 'https://twenty.com',
snippet: 'Open-source CRM\nBuilt with modern tech',
},
],
});
});
it('should default to DEFAULT_NUM_RESULTS and search with type "auto" and highlights when numResults is omitted', async () => {
searchAndContentsMock.mockResolvedValue({ results: [] });
await handler({ query: 'acme corp', category: 'company' });
expect(searchAndContentsMock).toHaveBeenCalledWith('acme corp', {
type: 'auto',
numResults: 10,
category: 'company',
highlights: { numSentences: 5 },
});
});
it('should forward an explicit numResults to Exa', async () => {
searchAndContentsMock.mockResolvedValue({ results: [] });
await handler({ query: 'acme corp', numResults: 3 });
expect(searchAndContentsMock).toHaveBeenCalledWith(
'acme corp',
expect.objectContaining({ numResults: 3 }),
);
});
it('should include the category in the success message when one is provided', async () => {
searchAndContentsMock.mockResolvedValue({
results: [{ title: 'A', url: 'https://a.com', highlights: ['x'] }],
});
const result = await handler({ query: 'openai', category: 'company' });
expect(result.message).toBe('Found 1 results for "openai" (category: company)');
});
it('should fall back to empty strings for a missing title and missing highlights', async () => {
searchAndContentsMock.mockResolvedValue({
results: [{ url: 'https://no-title.com' }],
});
const result = await handler({ query: 'edge case' });
expect(result.result).toEqual([
{ title: '', url: 'https://no-title.com', snippet: '' },
]);
});
it('should charge the Exa base price (7000 micro-credits) for up to DEFAULT_NUM_RESULTS results', async () => {
searchAndContentsMock.mockResolvedValue({
results: [{ title: 'A', url: 'https://a.com', highlights: ['x'] }],
});
await handler({ query: 'pricing base' });
expect(chargeCreditsMock).toHaveBeenCalledWith({
creditsUsedMicro: 7000,
operationType: 'WEB_SEARCH',
resourceContext: 'exa',
});
});
it('should add 1000 micro-credits per result beyond DEFAULT_NUM_RESULTS', async () => {
searchAndContentsMock.mockResolvedValue({
results: Array.from({ length: 12 }, (_, index) => ({
title: `Result ${index}`,
url: `https://example.com/${index}`,
highlights: ['snippet'],
})),
});
await handler({ query: 'pricing extra', numResults: 12 });
expect(chargeCreditsMock).toHaveBeenCalledWith(
expect.objectContaining({ creditsUsedMicro: 9000 }),
);
});
it('should return a failure result and not charge credits when the Exa search throws', async () => {
searchAndContentsMock.mockRejectedValue(new Error('Exa rate limit exceeded'));
const result = await handler({ query: 'boom' });
expect(result).toEqual({
success: false,
message: 'Web search failed for "boom"',
error: 'Exa rate limit exceeded',
});
expect(chargeCreditsMock).not.toHaveBeenCalled();
});
it('should time out and fail when Exa does not respond within the inner timeout', async () => {
vi.useFakeTimers();
searchAndContentsMock.mockReturnValue(new Promise<never>(() => {}));
const resultPromise = handler({ query: 'slow query' });
await vi.advanceTimersByTimeAsync(25_000);
const result = await resultPromise;
expect(result).toEqual({
success: false,
message: 'Web search failed for "slow query"',
error: 'Exa search timed out',
});
expect(chargeCreditsMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1 @@
export const DEFAULT_NUM_RESULTS = 10;
@@ -0,0 +1,11 @@
// Mirrors exa-js's `BaseSearchOptions['category']` union. Kept as a
// runtime list so the tool's JSON-Schema `enum` can reference it.
export const EXA_CATEGORIES = [
'company',
'research paper',
'news',
'pdf',
'personal site',
'financial report',
'people',
] as const;
@@ -0,0 +1,118 @@
import Exa from 'exa-js';
import { chargeCredits } from 'twenty-sdk/billing';
import { defineLogicFunction } from 'twenty-sdk/define';
import { DEFAULT_NUM_RESULTS } from './constants/default-num-results.constant';
import { exaWebSearchInputSchema } from './schemas/exa-web-search-input.schema';
import { type ExaWebSearchInput } from './types/exa-web-search-input.type';
// Number of sentences surfaced per result — keeps the snippet compact
// enough for an LLM to read many results without blowing the context.
const HIGHLIGHT_NUM_SENTENCES = 5;
// Inner bound — the runtime's `timeoutSeconds: 30` is the outer kill
// switch; this one ensures we return a clean error on a slow Exa response.
const EXA_SEARCH_TIMEOUT_MS = 25_000;
// Exa auto-search pricing (2025): $0.007 covers the first 10 results,
// $0.001 per additional result. Twenty charges in micro-credits where
// 1 USD = 1_000_000 micro-credits (DOLLAR_TO_CREDIT_MULTIPLIER).
const MICRO_CREDITS_PER_DOLLAR = 1_000_000;
const EXA_BASE_COST_DOLLARS = 0.007;
const EXA_COST_PER_ADDITIONAL_RESULT_DOLLARS = 0.001;
type ExaSearchResult = {
title: string;
url: string;
snippet: string;
};
type HandlerResult = {
success: boolean;
message: string;
result?: ExaSearchResult[];
error?: string;
};
const computeMicroCredits = (numResults: number): number => {
const additional = Math.max(0, numResults - DEFAULT_NUM_RESULTS);
const dollars =
EXA_BASE_COST_DOLLARS + additional * EXA_COST_PER_ADDITIONAL_RESULT_DOLLARS;
return Math.round(dollars * MICRO_CREDITS_PER_DOLLAR);
};
const handler = async (
parameters: ExaWebSearchInput,
): Promise<HandlerResult> => {
const apiKey = process.env.EXA_API_KEY;
if (!apiKey) {
return {
success: false,
message: 'Exa is not configured',
error:
'EXA_API_KEY is not set. The server admin must provide an Exa API key for this tool to work.',
};
}
const query = parameters.query;
const numResults = parameters.numResults ?? DEFAULT_NUM_RESULTS;
const category = parameters.category;
try {
const exa = new Exa(apiKey);
// exa-js has no built-in abort — race it manually.
const response = await Promise.race([
exa.searchAndContents(query, {
type: 'auto',
numResults,
category,
highlights: { numSentences: HIGHLIGHT_NUM_SENTENCES },
}),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Exa search timed out')),
EXA_SEARCH_TIMEOUT_MS,
),
),
]);
const results: ExaSearchResult[] = response.results.map((result) => ({
title: result.title ?? '',
url: result.url,
snippet: result.highlights?.join('\n') ?? '',
}));
await chargeCredits({
creditsUsedMicro: computeMicroCredits(results.length),
operationType: 'WEB_SEARCH',
resourceContext: 'exa',
});
return {
success: true,
message: `Found ${results.length} results for "${query}"${category ? ` (category: ${category})` : ''}`,
result: results,
};
} catch (error) {
return {
success: false,
message: `Web search failed for "${query}"`,
error: error instanceof Error ? error.message : 'Web search failed',
};
}
};
export default defineLogicFunction({
universalIdentifier: '4c6f9b2a-5d8e-4c2a-af18-3e0b9c6a7e4f',
name: 'exa_web_search',
description:
'Structured web search powered by Exa. Returns entity-aware results with category filtering (companies, people, research papers, news, and other content types). Prefer this when the query benefits from structured data or a specific category. For general real-time web browsing, prefer the native `web_search` tool when it is available.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: exaWebSearchInputSchema,
},
handler,
});
@@ -0,0 +1,31 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
import { DEFAULT_NUM_RESULTS } from '../constants/default-num-results.constant';
import { EXA_CATEGORIES } from '../constants/exa-categories.constant';
const MAX_NUM_RESULTS = 30;
export const exaWebSearchInputSchema: InputJsonSchema = {
type: 'object',
properties: {
query: {
type: 'string',
description:
'The search query to look up on the web. Be specific and include relevant keywords for better results.',
},
category: {
type: 'string',
enum: [...EXA_CATEGORIES],
description:
'Optional content category to focus the search. Use "company" for business/organization info, "people" for person profiles, "news" for recent articles, "research paper" for academic content.',
},
numResults: {
type: 'integer',
minimum: 1,
maximum: MAX_NUM_RESULTS,
description: `Number of search results to return. Defaults to ${DEFAULT_NUM_RESULTS}, max ${MAX_NUM_RESULTS}. Use more results when you need comprehensive coverage.`,
},
},
required: ['query'],
additionalProperties: false,
};
@@ -0,0 +1,7 @@
import { type EXA_CATEGORIES } from '../constants/exa-categories.constant';
export type ExaWebSearchInput = {
query: string;
category?: (typeof EXA_CATEGORIES)[number];
numResults?: number;
};
@@ -0,0 +1,24 @@
import { defineRole } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'9a55f3d2-f87c-4f1b-a0f3-5d1c6b8a2e4c';
// Exa's logic function never reads workspace data — it only reads
// EXA_API_KEY and calls Exa's external API — so the role needs no object
// permissions. Kept explicit so the manifest records the "zero data
// access" posture.
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Exa function role',
description: 'No-op role for the exa_web_search logic function',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [],
fieldPermissions: [],
});