@jarcelao/pi-exa-api 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/extensions/api-key.ts +12 -0
- package/extensions/content-types.ts +43 -0
- package/extensions/errors.ts +12 -0
- package/extensions/formatters.ts +167 -0
- package/extensions/index.ts +46 -0
- package/extensions/tools/code-context.ts +122 -0
- package/extensions/tools/fetch.ts +119 -0
- package/extensions/tools/search.ts +112 -0
- package/extensions/tools/shared.ts +106 -0
- package/extensions/types.ts +68 -0
- package/package.json +7 -7
- package/tests/api-key.test.ts +40 -0
- package/tests/content-type-mapping.test.ts +51 -0
- package/tests/extension-registration.test.ts +161 -0
- package/tests/formatting.test.ts +216 -0
- package/exa-search.test.ts +0 -851
- package/extensions/exa-search.ts +0 -646
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for Exa tools
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_MAX_BYTES,
|
|
10
|
+
DEFAULT_MAX_LINES,
|
|
11
|
+
formatSize,
|
|
12
|
+
truncateHead,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
import { getApiKey } from "../api-key.ts";
|
|
18
|
+
import { createMissingApiKeyError } from "../errors.ts";
|
|
19
|
+
import { formatToolOutputPreview } from "../formatters.ts";
|
|
20
|
+
|
|
21
|
+
/** Exa Context API base URL */
|
|
22
|
+
export const EXA_CONTEXT_API_URL = "https://api.exa.ai/context";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Require an API key to be configured, throwing if missing.
|
|
26
|
+
* @returns The API key
|
|
27
|
+
*/
|
|
28
|
+
export function requireApiKey(): string {
|
|
29
|
+
const apiKey = getApiKey();
|
|
30
|
+
if (!apiKey) {
|
|
31
|
+
throw createMissingApiKeyError();
|
|
32
|
+
}
|
|
33
|
+
return apiKey;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Truncate output and save to temp file if needed.
|
|
38
|
+
* @param output - The full output string
|
|
39
|
+
* @returns Object with the (possibly truncated) content string
|
|
40
|
+
*/
|
|
41
|
+
export async function truncateAndSave(output: string): Promise<string> {
|
|
42
|
+
const truncation = truncateHead(output, {
|
|
43
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
44
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let content = truncation.content;
|
|
48
|
+
|
|
49
|
+
if (truncation.truncated) {
|
|
50
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-exa-"));
|
|
51
|
+
const tempFile = join(tempDir, "output.txt");
|
|
52
|
+
await writeFile(tempFile, output, "utf8");
|
|
53
|
+
content += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
54
|
+
content += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
55
|
+
content += ` Full output saved to: ${tempFile}]`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return content;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format the tool call preview for TUI display.
|
|
63
|
+
* @param label - The tool label (e.g., "exa_search")
|
|
64
|
+
* @param preview - The main preview text
|
|
65
|
+
* @param desc - The description text
|
|
66
|
+
* @param theme - The theme object
|
|
67
|
+
*/
|
|
68
|
+
export function renderToolCall(label: string, preview: string, desc: string, theme: Theme): Text {
|
|
69
|
+
const truncatedPreview = preview.length > 50 ? preview.slice(0, 50) + "..." : preview;
|
|
70
|
+
const text =
|
|
71
|
+
theme.fg("toolTitle", theme.bold(`${label} `)) +
|
|
72
|
+
theme.fg("muted", truncatedPreview) +
|
|
73
|
+
theme.fg("dim", ` ${desc}`);
|
|
74
|
+
return new Text(text, 0, 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format the tool result for TUI display with optional header and preview.
|
|
79
|
+
* @param details - The details object with optional cost info
|
|
80
|
+
* @param headerText - The header text to display
|
|
81
|
+
* @param result - The full result object
|
|
82
|
+
* @param options - The display options
|
|
83
|
+
* @param theme - The theme object
|
|
84
|
+
* @param context - The context object
|
|
85
|
+
*/
|
|
86
|
+
export function renderToolResult(
|
|
87
|
+
headerText: string,
|
|
88
|
+
result: { content: Array<{ type: string; text?: string }> },
|
|
89
|
+
options: { expanded: boolean },
|
|
90
|
+
theme: Theme,
|
|
91
|
+
context: { lastComponent?: unknown },
|
|
92
|
+
): Text {
|
|
93
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
94
|
+
const preview = formatToolOutputPreview(result, options, theme);
|
|
95
|
+
text.setText(preview ? `${headerText}\n${preview}` : headerText);
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Format cost for display.
|
|
101
|
+
* @param cost - Cost object with total property
|
|
102
|
+
* @returns Formatted cost string or empty string
|
|
103
|
+
*/
|
|
104
|
+
export function formatCost(cost?: { total: number }): string {
|
|
105
|
+
return cost ? ` • $${cost.total.toFixed(6)}` : "";
|
|
106
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for Exa API extension
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Content type options
|
|
6
|
+
export type SearchContentType = "text" | "highlights" | "summary" | "none";
|
|
7
|
+
export type FetchContentType = "text" | "highlights" | "summary";
|
|
8
|
+
|
|
9
|
+
// Search result types
|
|
10
|
+
export interface ExaSearchResult {
|
|
11
|
+
title: string;
|
|
12
|
+
url: string;
|
|
13
|
+
publishedDate?: string | null;
|
|
14
|
+
author?: string | null;
|
|
15
|
+
text?: string;
|
|
16
|
+
highlights?: string[];
|
|
17
|
+
summary?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ExaSearchResponse {
|
|
21
|
+
results: ExaSearchResult[];
|
|
22
|
+
costDollars?: { total: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Tool detail types
|
|
26
|
+
export interface SearchDetails {
|
|
27
|
+
query: string;
|
|
28
|
+
numResults: number;
|
|
29
|
+
cost?: { total: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FetchDetails {
|
|
33
|
+
url: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
cost?: { total: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CodeContextDetails {
|
|
39
|
+
query: string;
|
|
40
|
+
resultsCount: number;
|
|
41
|
+
outputTokens: number;
|
|
42
|
+
cost?: { total: number };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Code context API response type
|
|
46
|
+
export interface CodeContextResponse {
|
|
47
|
+
requestId: string;
|
|
48
|
+
query: string;
|
|
49
|
+
response: string;
|
|
50
|
+
resultsCount: number;
|
|
51
|
+
costDollars: string | { total: number };
|
|
52
|
+
searchTime: number;
|
|
53
|
+
outputTokens: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Exa search options type
|
|
57
|
+
export interface SearchOptions {
|
|
58
|
+
numResults: number;
|
|
59
|
+
contents?: { text?: true; highlights?: true; summary?: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Exa getContents options type
|
|
63
|
+
export interface GetContentsOptions {
|
|
64
|
+
text?: true;
|
|
65
|
+
highlights?: true;
|
|
66
|
+
summary?: true;
|
|
67
|
+
maxCharacters?: number;
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarcelao/pi-exa-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Web search and content fetching for pi via the Exa API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"type": "git",
|
|
13
13
|
"url": "git+https://github.com/jarcelao/pi-exa-api.git"
|
|
14
14
|
},
|
|
15
|
-
"main": "./extensions/
|
|
15
|
+
"main": "./extensions/index.ts",
|
|
16
16
|
"scripts": {
|
|
17
17
|
"test": "vitest",
|
|
18
18
|
"test:run": "vitest run",
|
|
@@ -22,18 +22,18 @@
|
|
|
22
22
|
"format:check": "oxfmt --check"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"exa-js": "^2.
|
|
25
|
+
"exa-js": "^2.12.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^25.5.0",
|
|
29
|
-
"oxfmt": "^0.
|
|
29
|
+
"oxfmt": "^0.48.0",
|
|
30
30
|
"oxlint": "^1.56.0",
|
|
31
|
-
"typescript": "^
|
|
31
|
+
"typescript": "^6.0.3",
|
|
32
32
|
"vitest": "^4.1.0"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@
|
|
36
|
-
"@
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
36
|
+
"@earendil-works/pi-tui": "*",
|
|
37
37
|
"typebox": "*"
|
|
38
38
|
},
|
|
39
39
|
"pi": {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { getApiKey } from "../extensions/api-key.ts";
|
|
3
|
+
import { createMissingApiKeyError } from "../extensions/errors.ts";
|
|
4
|
+
|
|
5
|
+
describe("API Key Management", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
delete process.env.EXA_API_KEY;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("should return undefined when EXA_API_KEY is not set", () => {
|
|
11
|
+
delete process.env.EXA_API_KEY;
|
|
12
|
+
expect(getApiKey()).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should return API key when EXA_API_KEY is set", () => {
|
|
16
|
+
const testKey = "test-api-key-123";
|
|
17
|
+
process.env.EXA_API_KEY = testKey;
|
|
18
|
+
expect(getApiKey()).toBe(testKey);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should treat empty string as not configured", () => {
|
|
22
|
+
process.env.EXA_API_KEY = "";
|
|
23
|
+
expect(getApiKey()).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should throw descriptive error when API key is missing", () => {
|
|
27
|
+
const error = createMissingApiKeyError();
|
|
28
|
+
expect(error.message).toContain("EXA_API_KEY");
|
|
29
|
+
expect(error.message).toContain("environment variable");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("Error Handling", () => {
|
|
34
|
+
it("should create descriptive missing API key error", () => {
|
|
35
|
+
const error = createMissingApiKeyError();
|
|
36
|
+
expect(error.message).toContain("Exa API key");
|
|
37
|
+
expect(error.message).toContain("EXA_API_KEY");
|
|
38
|
+
expect(error.message).toContain("environment variable");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mapSearchContentType, mapFetchContentType } from "../extensions/content-types.ts";
|
|
3
|
+
|
|
4
|
+
describe("ContentType Mapping (Search)", () => {
|
|
5
|
+
it('should map "text" to contents.text', () => {
|
|
6
|
+
const result = mapSearchContentType("text");
|
|
7
|
+
expect(result).toEqual({ text: true });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should map "highlights" to contents.highlights', () => {
|
|
11
|
+
const result = mapSearchContentType("highlights");
|
|
12
|
+
expect(result).toEqual({ highlights: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should map "summary" to contents.summary', () => {
|
|
16
|
+
const result = mapSearchContentType("summary");
|
|
17
|
+
expect(result).toEqual({ summary: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should map "none" to undefined (metadata only)', () => {
|
|
21
|
+
const result = mapSearchContentType("none");
|
|
22
|
+
expect(result).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should default to highlights when not specified", () => {
|
|
26
|
+
const result = mapSearchContentType(undefined);
|
|
27
|
+
expect(result).toEqual({ highlights: true });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("ContentType Mapping (Fetch)", () => {
|
|
32
|
+
it('should map "text" to contents.text', () => {
|
|
33
|
+
const result = mapFetchContentType("text");
|
|
34
|
+
expect(result).toEqual({ text: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should map "highlights" to contents.highlights', () => {
|
|
38
|
+
const result = mapFetchContentType("highlights");
|
|
39
|
+
expect(result).toEqual({ highlights: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should map "summary" to contents.summary', () => {
|
|
43
|
+
const result = mapFetchContentType("summary");
|
|
44
|
+
expect(result).toEqual({ summary: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should default to text for fetch when not specified", () => {
|
|
48
|
+
const result = mapFetchContentType(undefined);
|
|
49
|
+
expect(result).toEqual({ text: true });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import exaSearchExtension from "../extensions/index.ts";
|
|
3
|
+
|
|
4
|
+
function createMockExtensionAPI() {
|
|
5
|
+
const tools: unknown[] = [];
|
|
6
|
+
const commands: Map<string, unknown> = new Map();
|
|
7
|
+
const eventHandlers: Map<string, unknown[]> = new Map();
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
registerTool: (tool: unknown) => tools.push(tool),
|
|
11
|
+
registerCommand: (name: string, command: unknown) => commands.set(name, command),
|
|
12
|
+
on: (event: string, handler: unknown) => {
|
|
13
|
+
const handlers = eventHandlers.get(event) || [];
|
|
14
|
+
handlers.push(handler);
|
|
15
|
+
eventHandlers.set(event, handlers);
|
|
16
|
+
},
|
|
17
|
+
getTools: () => tools,
|
|
18
|
+
getCommand: (name: string) => commands.get(name),
|
|
19
|
+
getEventHandlers: (event: string) => eventHandlers.get(event) || [],
|
|
20
|
+
findTool: (name: string) => tools.find((t: unknown) => (t as { name: string }).name === name),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setup() {
|
|
25
|
+
const api = createMockExtensionAPI();
|
|
26
|
+
exaSearchExtension(api as unknown as import("@earendil-works/pi-coding-agent").ExtensionAPI);
|
|
27
|
+
return api;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const mockTheme = { fg: (_name: string, text: string) => text };
|
|
31
|
+
|
|
32
|
+
describe("Extension Registration", () => {
|
|
33
|
+
it("should register all three tools with correct names and labels", () => {
|
|
34
|
+
const api = setup();
|
|
35
|
+
const tools = api.getTools();
|
|
36
|
+
expect(tools).toHaveLength(3);
|
|
37
|
+
|
|
38
|
+
const expected = [
|
|
39
|
+
{ name: "exa_search", label: "Exa Search" },
|
|
40
|
+
{ name: "exa_fetch", label: "Exa Fetch" },
|
|
41
|
+
{ name: "exa_code_context", label: "Exa Code Context" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const { name, label } of expected) {
|
|
45
|
+
const tool = api.findTool(name) as { name: string; label: string; execute: unknown };
|
|
46
|
+
expect(tool).toBeDefined();
|
|
47
|
+
expect(tool.name).toBe(name);
|
|
48
|
+
expect(tool.label).toBe(label);
|
|
49
|
+
expect(typeof tool.execute).toBe("function");
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should register /exa-status command", () => {
|
|
54
|
+
const api = setup();
|
|
55
|
+
const cmd = api.getCommand("exa-status") as { description: string; handler: Function };
|
|
56
|
+
expect(cmd).toBeDefined();
|
|
57
|
+
expect(cmd.description).toContain("API key");
|
|
58
|
+
expect(typeof cmd.handler).toBe("function");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should register session_start event handler", () => {
|
|
62
|
+
const api = setup();
|
|
63
|
+
expect(api.getEventHandlers("session_start")).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("exa_fetch renderResult", () => {
|
|
68
|
+
it("should display title and cost when both present", () => {
|
|
69
|
+
const api = setup();
|
|
70
|
+
const tool = api.findTool("exa_fetch") as { renderResult: Function };
|
|
71
|
+
const rendered = tool.renderResult(
|
|
72
|
+
{
|
|
73
|
+
content: [],
|
|
74
|
+
details: { url: "https://example.com", title: "Test Page", cost: { total: 0.000123 } },
|
|
75
|
+
},
|
|
76
|
+
{ expanded: false, isPartial: false },
|
|
77
|
+
mockTheme,
|
|
78
|
+
{ lastComponent: undefined },
|
|
79
|
+
);
|
|
80
|
+
expect(rendered.text).toContain("Test Page");
|
|
81
|
+
expect(rendered.text).toContain("$0.000123");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should display Fetched when no title", () => {
|
|
85
|
+
const api = setup();
|
|
86
|
+
const tool = api.findTool("exa_fetch") as { renderResult: Function };
|
|
87
|
+
const rendered = tool.renderResult(
|
|
88
|
+
{ content: [], details: { url: "https://example.com", cost: { total: 0.000456 } } },
|
|
89
|
+
{ expanded: false, isPartial: false },
|
|
90
|
+
mockTheme,
|
|
91
|
+
{ lastComponent: undefined },
|
|
92
|
+
);
|
|
93
|
+
expect(rendered.text).toContain("Fetched");
|
|
94
|
+
expect(rendered.text).toContain("$0.000456");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should display empty string when no details", () => {
|
|
98
|
+
const api = setup();
|
|
99
|
+
const tool = api.findTool("exa_fetch") as { renderResult: Function };
|
|
100
|
+
const rendered = tool.renderResult(
|
|
101
|
+
{ content: [] },
|
|
102
|
+
{ expanded: false, isPartial: false },
|
|
103
|
+
mockTheme,
|
|
104
|
+
{ lastComponent: undefined },
|
|
105
|
+
);
|
|
106
|
+
expect(rendered.text).toBe("");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("exa_code_context renderResult", () => {
|
|
111
|
+
it("should display stats and cost when details present", () => {
|
|
112
|
+
const api = setup();
|
|
113
|
+
const tool = api.findTool("exa_code_context") as { renderResult: Function };
|
|
114
|
+
const rendered = tool.renderResult(
|
|
115
|
+
{
|
|
116
|
+
content: [],
|
|
117
|
+
details: {
|
|
118
|
+
query: "React hooks",
|
|
119
|
+
resultsCount: 502,
|
|
120
|
+
outputTokens: 4805,
|
|
121
|
+
cost: { total: 1.0 },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{ expanded: false, isPartial: false },
|
|
125
|
+
mockTheme,
|
|
126
|
+
{ lastComponent: undefined },
|
|
127
|
+
);
|
|
128
|
+
expect(rendered.text).toContain("502 sources");
|
|
129
|
+
expect(rendered.text).toContain("4805 tokens");
|
|
130
|
+
expect(rendered.text).toContain("$1.000000");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should display stats without cost when cost missing", () => {
|
|
134
|
+
const api = setup();
|
|
135
|
+
const tool = api.findTool("exa_code_context") as { renderResult: Function };
|
|
136
|
+
const rendered = tool.renderResult(
|
|
137
|
+
{
|
|
138
|
+
content: [],
|
|
139
|
+
details: { query: "Express middleware", resultsCount: 100, outputTokens: 2000 },
|
|
140
|
+
},
|
|
141
|
+
{ expanded: false, isPartial: false },
|
|
142
|
+
mockTheme,
|
|
143
|
+
{ lastComponent: undefined },
|
|
144
|
+
);
|
|
145
|
+
expect(rendered.text).toContain("100 sources");
|
|
146
|
+
expect(rendered.text).toContain("2000 tokens");
|
|
147
|
+
expect(rendered.text).not.toContain("$");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should fall back to content text when no details", () => {
|
|
151
|
+
const api = setup();
|
|
152
|
+
const tool = api.findTool("exa_code_context") as { renderResult: Function };
|
|
153
|
+
const rendered = tool.renderResult(
|
|
154
|
+
{ content: [{ type: "text", text: "Some code context output here" }] },
|
|
155
|
+
{ expanded: false, isPartial: false },
|
|
156
|
+
mockTheme,
|
|
157
|
+
{ lastComponent: undefined },
|
|
158
|
+
);
|
|
159
|
+
expect(rendered.text).toContain("Some code context");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatSearchResults,
|
|
4
|
+
formatFetchResult,
|
|
5
|
+
formatCodeContextResult,
|
|
6
|
+
parseCostDollars,
|
|
7
|
+
} from "../extensions/formatters.ts";
|
|
8
|
+
|
|
9
|
+
describe("formatSearchResults", () => {
|
|
10
|
+
it("should format basic result fields", () => {
|
|
11
|
+
const formatted = formatSearchResults({
|
|
12
|
+
results: [
|
|
13
|
+
{
|
|
14
|
+
title: "Test Article",
|
|
15
|
+
url: "https://example.com/article",
|
|
16
|
+
publishedDate: "2024-01-15",
|
|
17
|
+
author: "John Doe",
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
expect(formatted).toContain("--- Result 1 ---");
|
|
22
|
+
expect(formatted).toContain("Title: Test Article");
|
|
23
|
+
expect(formatted).toContain("URL: https://example.com/article");
|
|
24
|
+
expect(formatted).toContain("Published: 2024-01-15");
|
|
25
|
+
expect(formatted).toContain("Author: John Doe");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should omit publishedDate and author when null", () => {
|
|
29
|
+
const formatted = formatSearchResults({
|
|
30
|
+
results: [{ title: "Test", url: "https://example.com", publishedDate: null, author: null }],
|
|
31
|
+
});
|
|
32
|
+
expect(formatted).toContain("Title: Test");
|
|
33
|
+
expect(formatted).not.toContain("Published:");
|
|
34
|
+
expect(formatted).not.toContain("Author:");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should format highlights with bullet points", () => {
|
|
38
|
+
const formatted = formatSearchResults({
|
|
39
|
+
results: [
|
|
40
|
+
{
|
|
41
|
+
title: "Test",
|
|
42
|
+
url: "https://example.com",
|
|
43
|
+
highlights: ["First highlight", "Second highlight"],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
expect(formatted).toContain("Highlights:");
|
|
48
|
+
expect(formatted).toContain(" • First highlight");
|
|
49
|
+
expect(formatted).toContain(" • Second highlight");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should format summary inline", () => {
|
|
53
|
+
const formatted = formatSearchResults({
|
|
54
|
+
results: [{ title: "Test", url: "https://example.com", summary: "This is a summary" }],
|
|
55
|
+
});
|
|
56
|
+
expect(formatted).toContain("Summary: This is a summary");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should truncate text preview to 500 characters", () => {
|
|
60
|
+
const longText = "a".repeat(600);
|
|
61
|
+
const formatted = formatSearchResults({
|
|
62
|
+
results: [{ title: "Test", url: "https://example.com", text: longText }],
|
|
63
|
+
});
|
|
64
|
+
expect(formatted).toContain("a".repeat(500));
|
|
65
|
+
expect(formatted).toContain("...");
|
|
66
|
+
expect(formatted).not.toContain("a".repeat(501));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should not append ellipsis for short text", () => {
|
|
70
|
+
const formatted = formatSearchResults({
|
|
71
|
+
results: [{ title: "Test", url: "https://example.com", text: "short" }],
|
|
72
|
+
});
|
|
73
|
+
expect(formatted).toContain("Text: short");
|
|
74
|
+
expect(formatted).not.toContain("short...");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should include cost when provided", () => {
|
|
78
|
+
const formatted = formatSearchResults({
|
|
79
|
+
results: [{ title: "Test", url: "https://example.com" }],
|
|
80
|
+
costDollars: { total: 0.000123 },
|
|
81
|
+
});
|
|
82
|
+
expect(formatted).toContain("Cost: $0.000123");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should omit cost line when not provided", () => {
|
|
86
|
+
const formatted = formatSearchResults({
|
|
87
|
+
results: [{ title: "Test", url: "https://example.com" }],
|
|
88
|
+
});
|
|
89
|
+
expect(formatted).not.toContain("Cost:");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should number multiple results sequentially", () => {
|
|
93
|
+
const formatted = formatSearchResults({
|
|
94
|
+
results: [
|
|
95
|
+
{ title: "First", url: "https://a.com" },
|
|
96
|
+
{ title: "Second", url: "https://b.com" },
|
|
97
|
+
{ title: "Third", url: "https://c.com" },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
expect(formatted).toContain("--- Result 1 ---");
|
|
101
|
+
expect(formatted).toContain("--- Result 2 ---");
|
|
102
|
+
expect(formatted).toContain("--- Result 3 ---");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("formatFetchResult", () => {
|
|
107
|
+
it("should format text content", () => {
|
|
108
|
+
const formatted = formatFetchResult(
|
|
109
|
+
{ title: "Page Title", url: "https://example.com", text: "Full page content" },
|
|
110
|
+
"text",
|
|
111
|
+
);
|
|
112
|
+
expect(formatted).toContain("Title: Page Title");
|
|
113
|
+
expect(formatted).toContain("Full page content");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should format highlights with bullet points", () => {
|
|
117
|
+
const formatted = formatFetchResult(
|
|
118
|
+
{ title: "Test", url: "https://example.com", highlights: ["Key point 1", "Key point 2"] },
|
|
119
|
+
"highlights",
|
|
120
|
+
);
|
|
121
|
+
expect(formatted).toContain("Highlights:");
|
|
122
|
+
expect(formatted).toContain(" • Key point 1");
|
|
123
|
+
expect(formatted).toContain(" • Key point 2");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should format summary", () => {
|
|
127
|
+
const formatted = formatFetchResult(
|
|
128
|
+
{ title: "Test", url: "https://example.com", summary: "This page is about..." },
|
|
129
|
+
"summary",
|
|
130
|
+
);
|
|
131
|
+
expect(formatted).toContain("Summary:");
|
|
132
|
+
expect(formatted).toContain("This page is about...");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should omit title when not provided", () => {
|
|
136
|
+
const formatted = formatFetchResult(
|
|
137
|
+
{ url: "https://example.com", text: "Content only" } as Parameters<
|
|
138
|
+
typeof formatFetchResult
|
|
139
|
+
>[0],
|
|
140
|
+
"text",
|
|
141
|
+
);
|
|
142
|
+
expect(formatted).toContain("URL: https://example.com");
|
|
143
|
+
expect(formatted).not.toContain("Title:");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should handle empty highlights array", () => {
|
|
147
|
+
const formatted = formatFetchResult(
|
|
148
|
+
{ title: "Test", url: "https://example.com", highlights: [] },
|
|
149
|
+
"highlights",
|
|
150
|
+
);
|
|
151
|
+
expect(formatted).toContain("URL: https://example.com");
|
|
152
|
+
expect(formatted).not.toContain("Highlights:");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should handle missing text for text contentType", () => {
|
|
156
|
+
const formatted = formatFetchResult({ title: "Test", url: "https://example.com" }, "text");
|
|
157
|
+
expect(formatted).toContain("URL: https://example.com");
|
|
158
|
+
// No text section should appear
|
|
159
|
+
expect(formatted).not.toContain("Text:");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("parseCostDollars", () => {
|
|
164
|
+
it("should parse JSON string costDollars", () => {
|
|
165
|
+
const parsed = parseCostDollars('{"total":0.007,"search":{"neural":0.007}}');
|
|
166
|
+
expect(parsed).toEqual({ total: 0.007, search: { neural: 0.007 } });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should pass through object costDollars unchanged", () => {
|
|
170
|
+
const costObject = { total: 1.5 };
|
|
171
|
+
expect(parseCostDollars(costObject)).toBe(costObject);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should handle zero cost", () => {
|
|
175
|
+
expect(parseCostDollars('{"total":0}').total).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("formatCodeContextResult", () => {
|
|
180
|
+
const baseResponse = {
|
|
181
|
+
requestId: "req_123",
|
|
182
|
+
query: "React hooks state management",
|
|
183
|
+
response: "## Example\n\n```js\nconst [count, setCount] = useState(0);\n```",
|
|
184
|
+
resultsCount: 502,
|
|
185
|
+
costDollars: '{"total":0.007}',
|
|
186
|
+
searchTime: 1.234,
|
|
187
|
+
outputTokens: 4805,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
it("should include all key fields", () => {
|
|
191
|
+
const formatted = formatCodeContextResult(baseResponse);
|
|
192
|
+
expect(formatted).toContain("Query: React hooks state management");
|
|
193
|
+
expect(formatted).toContain("Results: 502 sources");
|
|
194
|
+
expect(formatted).toContain("Output tokens: 4805");
|
|
195
|
+
expect(formatted).toContain("--- Code Context ---");
|
|
196
|
+
expect(formatted).toContain("## Example");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("should format cost from JSON string", () => {
|
|
200
|
+
const formatted = formatCodeContextResult(baseResponse);
|
|
201
|
+
expect(formatted).toContain("Cost: $0.007000");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should format cost from object", () => {
|
|
205
|
+
const formatted = formatCodeContextResult({ ...baseResponse, costDollars: { total: 1.5 } });
|
|
206
|
+
expect(formatted).toContain("Cost: $1.500000");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("should format cost with full decimal precision", () => {
|
|
210
|
+
const formatted = formatCodeContextResult({
|
|
211
|
+
...baseResponse,
|
|
212
|
+
costDollars: '{"total":0.123456}',
|
|
213
|
+
});
|
|
214
|
+
expect(formatted).toContain("Cost: $0.123456");
|
|
215
|
+
});
|
|
216
|
+
});
|