mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 11:31:38 +00:00
610 lines
17 KiB
TypeScript
610 lines
17 KiB
TypeScript
// Xai tests cover x search plugin behavior.
|
|
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createXSearchTool } from "./x-search.js";
|
|
|
|
const XAI_DOCUMENTED_HANDLE_LIMIT = 20;
|
|
|
|
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
|
return new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
...init,
|
|
});
|
|
}
|
|
|
|
function installXSearchFetch(payload?: Record<string, unknown>) {
|
|
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
|
|
Promise.resolve(
|
|
jsonResponse(
|
|
payload ?? {
|
|
output: [
|
|
{
|
|
type: "message",
|
|
content: [
|
|
{
|
|
type: "output_text",
|
|
text: "Found X posts",
|
|
annotations: [{ type: "url_citation", url: "https://x.com/openclaw/status/1" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
citations: ["https://x.com/openclaw/status/1"],
|
|
},
|
|
),
|
|
),
|
|
);
|
|
global.fetch = withFetchPreconnect(mockFetch);
|
|
return mockFetch;
|
|
}
|
|
|
|
function firstFetchCall(mockFetch: ReturnType<typeof installXSearchFetch>) {
|
|
const [call] = mockFetch.mock.calls;
|
|
if (!call) {
|
|
throw new Error("expected x_search fetch call");
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function firstFetchUrl(mockFetch: ReturnType<typeof installXSearchFetch>) {
|
|
const [url] = firstFetchCall(mockFetch);
|
|
return String(url);
|
|
}
|
|
|
|
function firstFetchInit(mockFetch: ReturnType<typeof installXSearchFetch>): RequestInit {
|
|
const [, init] = firstFetchCall(mockFetch);
|
|
if (!init || typeof init !== "object" || Array.isArray(init)) {
|
|
throw new Error("expected x_search fetch init");
|
|
}
|
|
return init as RequestInit;
|
|
}
|
|
|
|
function firstAuthorizationHeader(mockFetch: ReturnType<typeof installXSearchFetch>) {
|
|
const headers = firstFetchInit(mockFetch).headers;
|
|
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
|
|
throw new Error("expected x_search request headers");
|
|
}
|
|
return (headers as Record<string, string>).Authorization;
|
|
}
|
|
|
|
function parseFirstRequestBody(mockFetch: ReturnType<typeof installXSearchFetch>) {
|
|
const requestBody = firstFetchInit(mockFetch).body;
|
|
return JSON.parse(typeof requestBody === "string" ? requestBody : "{}") as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
}
|
|
|
|
function createConfiguredXSearchTool() {
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-config-test", // pragma: allowlist secret
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!tool) {
|
|
throw new Error("expected x_search tool to be configured");
|
|
}
|
|
return tool;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("xai x_search tool", () => {
|
|
it("describes query as the required instruction for the Grok X-search agent", () => {
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-plugin-key", // pragma: allowlist secret
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const parameters = tool?.parameters as
|
|
| { properties?: { query?: { description?: string } } }
|
|
| undefined;
|
|
const queryDescription = parameters?.properties?.query?.description;
|
|
|
|
expect(queryDescription).toContain("Natural-language instruction");
|
|
expect(queryDescription).toContain("Grok X-search agent");
|
|
expect(queryDescription).toContain("meaningful and non-empty");
|
|
expect(queryDescription).not.toContain("allowed_x_handles");
|
|
});
|
|
|
|
it("publishes xAI handle-filter constraints in the tool schema", () => {
|
|
const tool = createConfiguredXSearchTool();
|
|
const parameters = tool.parameters as {
|
|
properties?: Record<string, { description?: string; maxItems?: number }>;
|
|
};
|
|
|
|
for (const [key, counterpart] of [
|
|
["allowed_x_handles", "excluded_x_handles"],
|
|
["excluded_x_handles", "allowed_x_handles"],
|
|
] as const) {
|
|
expect(parameters.properties?.[key]?.maxItems).toBe(XAI_DOCUMENTED_HANDLE_LIMIT);
|
|
expect(parameters.properties?.[key]?.description).toContain(counterpart);
|
|
}
|
|
});
|
|
|
|
it("enables x_search when runtime config carries the shared xAI key", () => {
|
|
const tool = createXSearchTool({
|
|
config: {},
|
|
runtimeConfig: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "x-search-runtime-key", // pragma: allowlist secret
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(tool?.name).toBe("x_search");
|
|
});
|
|
|
|
it("enables x_search from an xAI auth profile and uses it for requests", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {},
|
|
auth: {
|
|
hasAuthForProvider: (providerId) => providerId === "xai",
|
|
resolveApiKeyForProvider: async (providerId) =>
|
|
providerId === "xai" ? "xai-profile-key" : undefined, // pragma: allowlist secret
|
|
},
|
|
});
|
|
|
|
expect(tool?.name).toBe("x_search");
|
|
await tool?.execute?.("x-search:auth-profile", {
|
|
query: "auth profile search",
|
|
});
|
|
|
|
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer xai-profile-key");
|
|
});
|
|
|
|
it("enables x_search when the xAI plugin web search key is configured", () => {
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-plugin-key", // pragma: allowlist secret
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(tool?.name).toBe("x_search");
|
|
});
|
|
|
|
it("uses the xAI Responses x_search tool with structured filters", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-config-test", // pragma: allowlist secret
|
|
},
|
|
xSearch: { maxTurns: 2 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await tool?.execute?.("x-search:1", {
|
|
query: "dinner recipes",
|
|
allowed_x_handles: ["openclaw"],
|
|
from_date: "2026-03-01",
|
|
to_date: "2026-03-20",
|
|
enable_image_understanding: true,
|
|
});
|
|
|
|
expect(mockFetch).toHaveBeenCalled();
|
|
expect(firstFetchUrl(mockFetch)).toContain("api.x.ai/v1/responses");
|
|
const body = parseFirstRequestBody(mockFetch);
|
|
expect(body.model).toBe("grok-4.3");
|
|
expect(body.store).toBe(false);
|
|
expect(body.reasoning).toEqual({ effort: "none" });
|
|
expect(body.max_turns).toBe(2);
|
|
expect(body.tools).toEqual([
|
|
{
|
|
type: "x_search",
|
|
allowed_x_handles: ["openclaw"],
|
|
from_date: "2026-03-01",
|
|
to_date: "2026-03-20",
|
|
enable_image_understanding: true,
|
|
},
|
|
]);
|
|
expect((result?.details as { citations?: string[] } | undefined)?.citations).toEqual([
|
|
"https://x.com/openclaw/status/1",
|
|
]);
|
|
});
|
|
|
|
it("rejects combined allow and exclude handle filters before calling xAI", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createConfiguredXSearchTool();
|
|
|
|
await expect(
|
|
tool.execute("x-search:combined-handle-filters", {
|
|
query: "dinner recipes",
|
|
allowed_x_handles: ["openclaw"],
|
|
excluded_x_handles: ["spam"],
|
|
}),
|
|
).rejects.toThrow("allowed_x_handles and excluded_x_handles cannot be used together");
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["allowed_x_handles", "excluded_x_handles"] as const)(
|
|
"accepts the xAI limit for %s",
|
|
async (key) => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createConfiguredXSearchTool();
|
|
const handles = Array.from(
|
|
{ length: XAI_DOCUMENTED_HANDLE_LIMIT },
|
|
(_, index) => `${key}-${index}`,
|
|
);
|
|
|
|
await tool.execute(`x-search:${key}:limit`, {
|
|
query: `${key} boundary`,
|
|
[key]: handles,
|
|
});
|
|
|
|
expect(parseFirstRequestBody(mockFetch).tools).toEqual([
|
|
{ type: "x_search", [key]: handles },
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each(["allowed_x_handles", "excluded_x_handles"] as const)(
|
|
"rejects %s above the xAI limit before calling xAI",
|
|
async (key) => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createConfiguredXSearchTool();
|
|
const handles = Array.from(
|
|
{ length: XAI_DOCUMENTED_HANDLE_LIMIT + 1 },
|
|
(_, index) => `${key}-${index}`,
|
|
);
|
|
|
|
await expect(
|
|
tool.execute(`x-search:${key}:over-limit`, {
|
|
query: `${key} over limit`,
|
|
[key]: handles,
|
|
}),
|
|
).rejects.toThrow(`${key} cannot contain more than ${XAI_DOCUMENTED_HANDLE_LIMIT} handles`);
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("routes x_search through plugin-owned xSearch.baseUrl", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-config-test", // pragma: allowlist secret
|
|
},
|
|
xSearch: {
|
|
enabled: true,
|
|
baseUrl: "https://api.x.ai/xai-search/v1/",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await tool?.execute?.("x-search:plugin-base-url", {
|
|
query: "base url route",
|
|
});
|
|
|
|
expect(firstFetchUrl(mockFetch)).toBe("https://api.x.ai/xai-search/v1/responses");
|
|
});
|
|
|
|
it("falls back to Grok web search baseUrl for x_search", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
tools: {
|
|
web: {
|
|
search: {
|
|
grok: {
|
|
apiKey: "xai-legacy-key", // pragma: allowlist secret
|
|
baseUrl: "https://api.x.ai/legacy/v1/",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await tool?.execute?.("x-search:legacy-grok-base-url", {
|
|
query: "legacy base url route",
|
|
});
|
|
|
|
expect(firstFetchUrl(mockFetch)).toBe("https://api.x.ai/legacy/v1/responses");
|
|
});
|
|
|
|
it("shares plugin webSearch.baseUrl with x_search when xSearch.baseUrl is unset", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-plugin-key", // pragma: allowlist secret
|
|
baseUrl: "https://api.x.ai/shared/v1/",
|
|
},
|
|
xSearch: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await tool?.execute?.("x-search:web-search-base-url", {
|
|
query: "shared base url route",
|
|
});
|
|
|
|
expect(firstFetchUrl(mockFetch)).toBe("https://api.x.ai/shared/v1/responses");
|
|
});
|
|
|
|
it("reuses the xAI plugin web search key for x_search requests", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-plugin-key", // pragma: allowlist secret
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await tool?.execute?.("x-search:plugin-key", {
|
|
query: "latest post from huntharo",
|
|
});
|
|
|
|
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer xai-plugin-key");
|
|
});
|
|
|
|
it("reports malformed x_search JSON as a provider error", async () => {
|
|
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
|
|
Promise.resolve(
|
|
new Response("{ nope", {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
global.fetch = withFetchPreconnect(mockFetch);
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-plugin-key", // pragma: allowlist secret
|
|
},
|
|
xSearch: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
tool?.execute?.("x-search:malformed-json", {
|
|
query: "malformed x_search response probe",
|
|
}),
|
|
).rejects.toThrow("xAI X search failed: malformed JSON response");
|
|
});
|
|
|
|
it("rejects x_search success JSON without answer text", async () => {
|
|
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
|
|
Promise.resolve(jsonResponse({ output: [] })),
|
|
);
|
|
global.fetch = withFetchPreconnect(mockFetch);
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-plugin-key", // pragma: allowlist secret
|
|
},
|
|
xSearch: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
tool?.execute?.("x-search:missing-text", {
|
|
query: "malformed x_search missing text probe",
|
|
}),
|
|
).rejects.toThrow("xAI X search failed: malformed JSON response");
|
|
});
|
|
|
|
it("prefers the active runtime config for shared xAI keys", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: { source: "env", provider: "default", id: "X_SEARCH_KEY_REF" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
runtimeConfig: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "x-search-runtime-key", // pragma: allowlist secret
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await tool?.execute?.("x-search:runtime-key", {
|
|
query: "runtime key search",
|
|
});
|
|
|
|
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer x-search-runtime-key");
|
|
});
|
|
|
|
it("reuses the legacy grok web search key for x_search requests", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
tools: {
|
|
web: {
|
|
search: {
|
|
grok: {
|
|
apiKey: "xai-legacy-key", // pragma: allowlist secret
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await tool?.execute?.("x-search:legacy-key", {
|
|
query: "latest legacy-key post from huntharo",
|
|
});
|
|
|
|
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer xai-legacy-key");
|
|
});
|
|
|
|
it("uses migrated runtime auth when the source config still carries legacy x_search apiKey", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
tools: {
|
|
web: {
|
|
x_search: {
|
|
apiKey: "legacy-x-search-key", // pragma: allowlist secret
|
|
enabled: true,
|
|
} as Record<string, unknown>,
|
|
},
|
|
},
|
|
},
|
|
runtimeConfig: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "migrated-runtime-key", // pragma: allowlist secret
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await tool?.execute?.("x-search:migrated-runtime-key", {
|
|
query: "migrated runtime auth",
|
|
});
|
|
|
|
expect(firstAuthorizationHeader(mockFetch)).toBe("Bearer migrated-runtime-key");
|
|
});
|
|
|
|
it("rejects invalid date ordering before calling xAI", async () => {
|
|
const mockFetch = installXSearchFetch();
|
|
const tool = createXSearchTool({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
xai: {
|
|
config: {
|
|
webSearch: {
|
|
apiKey: "xai-config-test", // pragma: allowlist secret
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
tool?.execute?.("x-search:bad-dates", {
|
|
query: "dinner recipes",
|
|
from_date: "2026-03-20",
|
|
to_date: "2026-03-01",
|
|
}),
|
|
).rejects.toThrow(/from_date must be on or before to_date/i);
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|