diff --git a/orbio-openclaw-plugin/src/index.ts b/orbio-openclaw-plugin/src/index.ts index f6c3c7e..53be1ca 100644 --- a/orbio-openclaw-plugin/src/index.ts +++ b/orbio-openclaw-plugin/src/index.ts @@ -46,6 +46,7 @@ type AccountSearchResponse = { request_id: string; snapshot: string; snapshot_date: string; + spec?: JsonRecord; accounts: JsonRecord[]; has_more: boolean; next_cursor: string | null; @@ -55,6 +56,7 @@ type ExportCreateResponse = { request_id: string; snapshot: string; snapshot_date: string; + spec?: JsonRecord; preview_accounts: JsonRecord[]; export: { export_id: string; @@ -71,12 +73,25 @@ type ExportStatusResponse = { export_id: string; status: string; format: string; + snapshot?: string; + snapshot_date?: string; row_count: number | null; size_bytes: number | null; + object_key?: string | null; expires_at: string | null; download_url: string | null; }; +type SpecResponse = { + spec: JsonRecord; +}; + +type OutputSpec = { + format: "json" | "csv" | "html"; + include_explain: boolean; + fields: string[]; +}; + const SAFE_DEFAULT_FIELDS = [ "cnpj", "legal_name", @@ -365,12 +380,30 @@ function parseNonNegativeInt(value: unknown, fallback: number): number { return Math.floor(value); } +function toTrimmedString(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "boolean" || value == null) { + return String(value ?? "").trim(); + } + try { + return String(value).trim(); + } catch { + return ""; + } +} + +function asJsonRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" ? (value as JsonRecord) : null; +} + function parseBoolean(value: unknown, fallback: boolean): boolean { if (typeof value === "boolean") { return value; } - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); + if (typeof value === "string" || value instanceof String) { + const normalized = toTrimmedString(value).toLowerCase(); if (["1", "true", "yes", "on"].includes(normalized)) { return true; } @@ -382,7 +415,7 @@ function parseBoolean(value: unknown, fallback: boolean): boolean { } function normalizeChannel(value: unknown): string { - const raw = String(value ?? "").trim().toLowerCase(); + const raw = toTrimmedString(value).toLowerCase(); if (!raw) { return "chat"; } @@ -396,11 +429,34 @@ function normalizeBaseUrl(value: string): string { function readConfig(api: unknown): OrbioPluginConfig { const asRecord = (api ?? {}) as JsonRecord; - const rawConfig = ((asRecord.config ?? {}) as JsonRecord) ?? {}; - const env = ((asRecord.env ?? {}) as Record) ?? {}; - - const baseUrl = String(rawConfig.baseUrl ?? env.ORBIO_BASE_URL ?? "").trim(); - const apiKey = String(rawConfig.apiKey ?? env.ORBIO_API_KEY ?? "").trim(); + const pluginConfigEnvelope = asJsonRecord(asRecord.pluginConfig); + const pluginConfig = + asJsonRecord(pluginConfigEnvelope?.config) ?? + pluginConfigEnvelope; + const rootConfig = asJsonRecord(asRecord.config); + const rootPlugins = asJsonRecord(rootConfig?.plugins); + const rootPluginEntries = asJsonRecord(rootPlugins?.entries); + const rootPluginEntry = asJsonRecord(rootPluginEntries?.[PLUGIN_ID]); + const rootPluginConfig = + asJsonRecord(rootPluginEntry?.config) ?? + rootPluginEntry; + const legacyConfig = + rootConfig && + (Object.prototype.hasOwnProperty.call(rootConfig, "baseUrl") || + Object.prototype.hasOwnProperty.call(rootConfig, "apiKey")) + ? rootConfig + : null; + const rawConfig = + pluginConfig ?? + rootPluginConfig ?? + legacyConfig ?? + {}; + const envSource = asJsonRecord(asRecord.env); + const env = + ((envSource ?? process.env) as Record) ?? {}; + + const baseUrl = toTrimmedString(rawConfig.baseUrl ?? env.ORBIO_BASE_URL ?? ""); + const apiKey = toTrimmedString(rawConfig.apiKey ?? env.ORBIO_API_KEY ?? ""); if (!baseUrl) { throw new Error("Missing plugin config: baseUrl"); @@ -414,7 +470,7 @@ function readConfig(api: unknown): OrbioPluginConfig { const retryCount = Math.min(3, parseNonNegativeInt(rawConfig.retryCount, 1)); const retryBackoffMs = parsePositiveInt(rawConfig.retryBackoffMs, 300); const capabilitiesTtlMs = parsePositiveInt(rawConfig.capabilitiesTtlMs, 60_000); - const workspaceId = String(rawConfig.workspaceId ?? env.ORBIO_WORKSPACE_ID ?? "default").trim(); + const workspaceId = toTrimmedString(rawConfig.workspaceId ?? env.ORBIO_WORKSPACE_ID ?? "default"); const channel = normalizeChannel(rawConfig.channel ?? env.ORBIO_CHANNEL ?? "chat"); const sendExecutionContext = parseBoolean( rawConfig.sendExecutionContext ?? env.ORBIO_SEND_EXECUTION_CONTEXT, @@ -718,19 +774,48 @@ export default function registerOrbioPlugin(api: unknown): unknown { } }; + const resolveSpecFromQuery = async ( + queryText: string, + limit: number, + output: OutputSpec, + ): Promise => { + const payload = await http.request("POST", "/v1/specs", { + query_text: queryText, + limit, + output, + include_explain: false, + }); + const generatedSpec = asJsonRecord(payload?.spec); + if (!generatedSpec) { + throw new Error("Spec generation failed: /v1/specs returned empty spec."); + } + + const normalizedPayload = await http.request("POST", "/v1/specs/normalize", { + spec: generatedSpec, + }); + const normalizedSpec = asJsonRecord(normalizedPayload?.spec); + if (!normalizedSpec) { + throw new Error("Spec normalization failed: /v1/specs/normalize returned empty spec."); + } + return normalizedSpec; + }; + const doSearch = async (args: SearchToolInput): Promise => { const caps = await getCapabilities(); const withContact = Boolean(args.with_contact); const { fields, contactGranted } = chooseOutputFields(caps.field_allowlist, withContact); + const limit = clampLimit(args.limit); + const output: OutputSpec = { + format: "json", + include_explain: false, + fields, + }; + const spec = await resolveSpecFromQuery(args.query_text, limit, output); const payload = await http.request("POST", "/v1/accounts/search", { - query_text: args.query_text, - limit: clampLimit(args.limit), - output: { - format: "json", - include_explain: false, - fields, - }, + spec, + limit, + output, }); return renderSearchText(payload, { @@ -745,15 +830,18 @@ export default function registerOrbioPlugin(api: unknown): unknown { const withContact = Boolean(args.with_contact); const { fields, contactGranted } = chooseOutputFields(caps.field_allowlist, withContact); const format = args.format ?? "csv"; + const limit = clampLimit(args.limit); + const output: OutputSpec = { + format, + include_explain: false, + fields, + }; + const spec = await resolveSpecFromQuery(args.query_text, limit, output); const requestBody = { - query_text: args.query_text, - limit: clampLimit(args.limit), - output: { - format, - include_explain: false, - fields, - }, + spec, + limit, + output, }; const idempotencyKey = buildIdempotencyKey("export", requestBody); @@ -782,11 +870,13 @@ export default function registerOrbioPlugin(api: unknown): unknown { const resolveCommandRaw = (args: CommandToolInput): string => { const raw = args.command ?? args.command_arg ?? args.commandArg; const commandName = args.command_name ?? args.commandName; - if (raw && raw.trim()) { - return raw.trim(); + const rawText = toTrimmedString(raw); + if (rawText) { + return rawText; } - if (commandName && commandName.trim()) { - return commandName.trim(); + const commandText = toTrimmedString(commandName); + if (commandText) { + return commandText; } return ""; }; @@ -818,16 +908,76 @@ export default function registerOrbioPlugin(api: unknown): unknown { return doExportStatus({ export_id: parsed.exportId }); }; + type RegisterToolLegacyFn = ( + name: string, + spec: { + description: string; + parameters: unknown; + optional?: boolean; + }, + handler: (args: unknown) => Promise, + ) => unknown; + + type RegisterToolModernFn = ( + tool: { + name: string; + description: string; + parameters: unknown; + execute: (id: string, args: unknown) => Promise; + }, + options?: { + optional?: boolean; + }, + ) => unknown; + const pluginApi = api as { - registerTool: ( - name: string, - spec: { - description: string; - parameters: unknown; - optional?: boolean; - }, - handler: (args: any) => Promise, - ) => unknown; + registerTool: RegisterToolLegacyFn | RegisterToolModernFn; + }; + + const registerToolCompat = ( + name: string, + description: string, + parameters: unknown, + handler: (args: unknown) => Promise, + ): unknown => { + const registerTool = pluginApi.registerTool as (...args: unknown[]) => unknown; + + // OpenClaw 2026+ expects registerTool({ name, description, parameters, execute }, { optional }). + // Keep a fallback for older runtimes that still use (name, spec, handler). + const registerModern = () => + (pluginApi.registerTool as RegisterToolModernFn)( + { + name, + description, + parameters, + execute: async (_id, args) => handler(args), + }, + { optional: true }, + ); + + const registerLegacy = () => + (pluginApi.registerTool as RegisterToolLegacyFn)( + name, + { + description, + parameters, + optional: true, + }, + handler, + ); + + if (registerTool.length >= 3) { + return registerLegacy(); + } + + try { + return registerModern(); + } catch (error) { + if (error instanceof TypeError) { + return registerLegacy(); + } + throw error; + } }; return { @@ -835,45 +985,33 @@ export default function registerOrbioPlugin(api: unknown): unknown { name: PLUGIN_NAME, description: "Official Orbio account discovery tools for OpenClaw.", tools: [ - pluginApi.registerTool( + registerToolCompat( "orbio_search", - { - description: - "Search Brazilian companies with chat-safe defaults. Use with_contact=true to request contact fields when plan allows.", - parameters: SearchToolInput, - optional: true, - }, - async (args: SearchToolInput) => runGuarded("orbio_search", () => doSearch(args)), + "Search Brazilian companies with chat-safe defaults. Use with_contact=true to request contact fields when plan allows.", + SearchToolInput, + async (args: unknown) => + runGuarded("orbio_search", () => doSearch(args as SearchToolInput)), ), - pluginApi.registerTool( + registerToolCompat( "orbio_export", - { - description: - "Create Orbio export jobs (csv/html). Uses Idempotency-Key and chat-safe field policy.", - parameters: ExportToolInput, - optional: true, - }, - async (args: ExportToolInput) => runGuarded("orbio_export", () => doExport(args)), + "Create Orbio export jobs (csv/html). Uses Idempotency-Key and chat-safe field policy.", + ExportToolInput, + async (args: unknown) => + runGuarded("orbio_export", () => doExport(args as ExportToolInput)), ), - pluginApi.registerTool( + registerToolCompat( "orbio_export_status", - { - description: "Get current status for an Orbio export job.", - parameters: ExportStatusToolInput, - optional: true, - }, - async (args: ExportStatusToolInput) => - runGuarded("orbio_export_status", () => doExportStatus(args)), + "Get current status for an Orbio export job.", + ExportStatusToolInput, + async (args: unknown) => + runGuarded("orbio_export_status", () => doExportStatus(args as ExportStatusToolInput)), ), - pluginApi.registerTool( + registerToolCompat( "orbio_command", - { - description: - "Command dispatcher for /orbio slash commands. Examples: search, export, export-status.", - parameters: CommandToolInput, - optional: true, - }, - async (args: CommandToolInput) => runGuarded("orbio_command", () => doCommand(args)), + "Command dispatcher for /orbio slash commands. Examples: search, export, export-status.", + CommandToolInput, + async (args: unknown) => + runGuarded("orbio_command", () => doCommand(args as CommandToolInput)), ), ], }; diff --git a/orbio-openclaw-plugin/tests/index.test.ts b/orbio-openclaw-plugin/tests/index.test.ts index 3291649..2f2fe9a 100644 --- a/orbio-openclaw-plugin/tests/index.test.ts +++ b/orbio-openclaw-plugin/tests/index.test.ts @@ -13,6 +13,14 @@ type ToolSpec = { }; type ToolHandler = (args: unknown) => Promise; +type ToolExecute = (id: string, args: unknown) => Promise; + +type ModernToolRegistration = { + name: string; + description: string; + parameters: unknown; + execute: ToolExecute; +}; type SetupOptions = { config?: Record; @@ -94,6 +102,16 @@ function searchResponse(accountsCount = 3): Response { }); } +function specResponse(spec: Record = { kind: "account_query" }): Response { + return jsonResponse({ spec }); +} + +function normalizedSpecResponse( + spec: Record = { kind: "account_query", normalized: true }, +): Response { + return jsonResponse({ spec }); +} + function exportResponse(): Response { return jsonResponse({ request_id: "req-export", @@ -153,7 +171,22 @@ function setupPlugin(options?: SetupOptions): { ...(options?.config ?? {}), }, env: options?.env ?? {}, - registerTool(name: string, spec: ToolSpec, handler: ToolHandler): unknown { + registerTool(...args: unknown[]): unknown { + const first = args[0]; + if (first && typeof first === "object" && "name" in first && "execute" in first) { + const tool = first as ModernToolRegistration; + const optionsRecord = (args[1] ?? {}) as Record; + const optional = optionsRecord.optional === true; + handlers.set(tool.name, async (payload) => tool.execute("test-run", payload)); + specs.set(tool.name, { + description: tool.description, + parameters: tool.parameters, + optional, + }); + return { name: tool.name }; + } + + const [name, spec, handler] = args as [string, ToolSpec, ToolHandler]; handlers.set(name, handler); specs.set(name, spec); return { name }; @@ -255,9 +288,142 @@ describe("orbio-openclaw plugin", () => { expect(() => registerOrbioPlugin(null)).toThrow("Missing plugin config: baseUrl"); }); - it("reads credentials from env and normalizes baseUrl", async () => { + it("accepts pluginConfig envelope from newer OpenClaw runtime", async () => { + fetchMock.mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()).mockResolvedValueOnce(searchResponse(1)); + + const handlers = new Map(); + const specs = new Map(); + const api = { + config: { meta: { source: "global-config" } }, + pluginConfig: { + config: { + baseUrl: "https://api.orbio.test", + apiKey: "api-key", + workspaceId: "workspace-1", + timeoutMs: 1000, + maxRequestsPerMinute: 30, + retryCount: 0, + retryBackoffMs: 0, + capabilitiesTtlMs: 60000, + }, + }, + registerTool(name: string, spec: ToolSpec, handler: ToolHandler): unknown { + handlers.set(name, handler); + specs.set(name, spec); + return { name }; + }, + }; + + registerOrbioPlugin(api); + expect(specs.has("orbio_search")).toBe(true); + + const text = await invokeTool(handlers, "orbio_search", { query_text: "software b2b" }); + expect(text).toContain("Search completed."); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.orbio.test/v1/capabilities"); + }); + + it("registers tools using modern registerTool signature when available", async () => { + fetchMock.mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()).mockResolvedValueOnce(searchResponse(1)); + + const handlers = new Map(); + const specs = new Map(); + const api = { + config: { + baseUrl: "https://api.orbio.test", + apiKey: "api-key", + }, + registerTool( + tool: ModernToolRegistration, + options?: { + optional?: boolean; + }, + ): unknown { + if (!tool || typeof tool !== "object" || typeof tool.execute !== "function") { + throw new Error("expected modern registerTool signature"); + } + handlers.set(tool.name, async (payload) => tool.execute("test-run", payload)); + specs.set(tool.name, { + description: tool.description, + parameters: tool.parameters, + optional: options?.optional === true, + }); + return { name: tool.name }; + }, + }; + + registerOrbioPlugin(api); + expect(specs.get("orbio_search")?.optional).toBe(true); + + const text = await invokeTool(handlers, "orbio_search", { query_text: "software b2b" }); + expect(text).toContain("Search completed."); + }); + + it("falls back to legacy registerTool signature when modern call fails", async () => { + fetchMock.mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()).mockResolvedValueOnce(searchResponse(1)); + + const handlers = new Map(); + const specs = new Map(); + const api = { + config: { + baseUrl: "https://api.orbio.test", + apiKey: "api-key", + }, + registerTool(...args: unknown[]): unknown { + if (typeof args[0] !== "string") { + throw new TypeError("legacy runtime expects registerTool(name, spec, handler)"); + } + const [name, spec, handler] = args as [string, ToolSpec, ToolHandler]; + handlers.set(name, handler); + specs.set(name, spec); + return { name }; + }, + }; + + registerOrbioPlugin(api); + expect(specs.get("orbio_search")?.optional).toBe(true); + + const text = await invokeTool(handlers, "orbio_search", { query_text: "software b2b" }); + expect(text).toContain("Search completed."); + }); + + it("rethrows non-TypeError registerTool failures in modern mode", () => { + const api = { + config: { + baseUrl: "https://api.orbio.test", + apiKey: "api-key", + }, + registerTool(): unknown { + throw new Error("boom modern"); + }, + }; + + expect(() => registerOrbioPlugin(api)).toThrow("boom modern"); + }); + + it("returns clear error when /v1/specs returns no spec", async () => { fetchMock .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(jsonResponse({})); + + const { handlers } = setupPlugin(); + const text = await invokeTool(handlers, "orbio_search", { query_text: "software b2b" }); + expect(text).toContain("Spec generation failed: /v1/specs returned empty spec."); + }); + + it("returns clear error when /v1/specs/normalize returns no spec", async () => { + fetchMock + .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(specResponse()) + .mockResolvedValueOnce(jsonResponse({})); + + const { handlers } = setupPlugin(); + const text = await invokeTool(handlers, "orbio_search", { query_text: "software b2b" }); + expect(text).toContain("Spec normalization failed: /v1/specs/normalize returned empty spec."); + }); + + it("reads credentials from env and normalizes baseUrl", async () => { + fetchMock + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(1)); const { handlers } = setupPlugin({ @@ -290,7 +456,7 @@ describe("orbio-openclaw plugin", () => { it("supports env-only workspace identity for plugin-scoped throttling", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(1)); const { handlers } = setupPlugin({ @@ -319,9 +485,9 @@ describe("orbio-openclaw plugin", () => { it("parses execution-context env toggles and channel normalization", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(1)) - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(1)); const withDisabledHeader = setupPlugin({ @@ -343,12 +509,12 @@ describe("orbio-openclaw plugin", () => { }, }); await invokeTool(withBlankChannel.handlers, "orbio_search", { query_text: "blank channel" }); - expect(executionContextHeaderAt(2).channel).toBe("chat"); + expect(executionContextHeaderAt(5).channel).toBe("chat"); }); it("searches with safe defaults and clamps large limits", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])) + .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(12)); const { handlers } = setupPlugin(); @@ -363,17 +529,17 @@ describe("orbio-openclaw plugin", () => { expect((payload.accounts as unknown[]).length).toBe(10); expect(payload.fields).toEqual(SAFE_FIELDS); - const searchBody = requestBodyAt(1); + const searchBody = requestBodyAt(3); expect(searchBody.limit).toBe(50_000); expect((searchBody.output as Record).format).toBe("json"); expect((searchBody.output as Record).include_explain).toBe(false); - const searchHeader = executionContextHeaderAt(1); + const searchHeader = executionContextHeaderAt(3); expect(searchHeader.integration).toBe("openclaw"); }); it("allows opting out of execution-context header", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(1)); const { handlers } = setupPlugin({ @@ -388,7 +554,7 @@ describe("orbio-openclaw plugin", () => { it("enables contact fields only with explicit opt-in and allowlist", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])) + .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(2)); const { handlers } = setupPlugin(); @@ -410,7 +576,7 @@ describe("orbio-openclaw plugin", () => { }); it("keeps responses masked when contact fields are not allowed", async () => { - fetchMock.mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(searchResponse(1)); + fetchMock.mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()).mockResolvedValueOnce(searchResponse(1)); const { handlers } = setupPlugin(); const text = await invokeTool(handlers, "orbio_search", { @@ -425,7 +591,7 @@ describe("orbio-openclaw plugin", () => { it("creates exports with idempotency key and format flags", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])) + .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(exportResponse()); const { handlers } = setupPlugin(); @@ -441,18 +607,18 @@ describe("orbio-openclaw plugin", () => { expect((payload.export as Record).export_id).toBe("exp-123"); expect((payload.fields as string[]).includes("email")).toBe(true); - const exportInit = requestInitAt(1); + const exportInit = requestInitAt(3); const headers = headerRecord(exportInit); expect(headers["Idempotency-Key"]).toMatch(/^openclaw:export:/); - const exportBody = requestBodyAt(1); + const exportBody = requestBodyAt(3); expect((exportBody.output as Record).format).toBe("html"); expect(exportBody.limit).toBe(30); }); it("uses csv as default export format when omitted", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse(SAFE_FIELDS)) + .mockResolvedValueOnce(capabilitiesResponse(SAFE_FIELDS)).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(exportResponse()); const { handlers } = setupPlugin(); @@ -461,12 +627,12 @@ describe("orbio-openclaw plugin", () => { with_contact: false, }); - const exportBody = requestBodyAt(1); + const exportBody = requestBodyAt(3); expect((exportBody.output as Record).format).toBe("csv"); }); it("adds masking note for exports when contact is requested but unavailable", async () => { - fetchMock.mockResolvedValueOnce(capabilitiesResponse(SAFE_FIELDS)).mockResolvedValueOnce(exportResponse()); + fetchMock.mockResolvedValueOnce(capabilitiesResponse(SAFE_FIELDS)).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()).mockResolvedValueOnce(exportResponse()); const { handlers } = setupPlugin(); const text = await invokeTool(handlers, "orbio_export", { @@ -492,7 +658,7 @@ describe("orbio-openclaw plugin", () => { it("supports command-dispatch with quoted args and aliases", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])) + .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(2)) .mockResolvedValueOnce(exportStatusResponse()); @@ -514,7 +680,7 @@ describe("orbio-openclaw plugin", () => { it("supports export through command dispatch", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])) + .mockResolvedValueOnce(capabilitiesResponse([...SAFE_FIELDS, ...CONTACT_FIELDS])).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(exportResponse()); const { handlers } = setupPlugin(); @@ -585,7 +751,7 @@ describe("orbio-openclaw plugin", () => { it("enforces plugin-side rate limiting per workspace and tool", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(1)); const { handlers } = setupPlugin({ @@ -597,13 +763,14 @@ describe("orbio-openclaw plugin", () => { expect(first).toContain("Search completed."); expect(second).toContain("Rate limited by plugin policy"); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(4); }); it("caches capabilities for the configured TTL", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(1)) + .mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(searchResponse(1)); const { handlers } = setupPlugin({ @@ -617,11 +784,11 @@ describe("orbio-openclaw plugin", () => { String(call[0]).endsWith("/v1/capabilities"), ); expect(capabilityCalls).toHaveLength(1); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(7); }); it("rejects search when allowlist has no safe fields", async () => { - fetchMock.mockResolvedValueOnce(capabilitiesResponse(["email"])); + fetchMock.mockResolvedValueOnce(capabilitiesResponse(["email"])).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()); const { handlers } = setupPlugin(); const text = await invokeTool(handlers, "orbio_search", { query_text: "invalid" }); @@ -632,7 +799,7 @@ describe("orbio-openclaw plugin", () => { it("retries transient 5xx responses", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(jsonResponse({ detail: "downstream" }, 503)) .mockResolvedValueOnce(searchResponse(1)); @@ -642,12 +809,12 @@ describe("orbio-openclaw plugin", () => { const text = await invokeTool(handlers, "orbio_search", { query_text: "retry" }); expect(text).toContain("Search completed."); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(5); }); it("retries transient network failures", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockRejectedValueOnce(new TypeError("socket closed")) .mockResolvedValueOnce(searchResponse(1)); @@ -657,14 +824,14 @@ describe("orbio-openclaw plugin", () => { const text = await invokeTool(handlers, "orbio_search", { query_text: "retry network" }); expect(text).toContain("Search completed."); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(5); }); it("maps timeout abort errors to a clear API message", async () => { const abortError = new Error("timeout"); abortError.name = "AbortError"; - fetchMock.mockResolvedValueOnce(capabilitiesResponse()).mockRejectedValueOnce(abortError); + fetchMock.mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()).mockRejectedValueOnce(abortError); const { handlers } = setupPlugin({ config: { retryCount: 0, timeoutMs: 1500 }, @@ -675,7 +842,7 @@ describe("orbio-openclaw plugin", () => { }); it("maps direct network failures without retry to network error message", async () => { - fetchMock.mockResolvedValueOnce(capabilitiesResponse()).mockRejectedValueOnce(new TypeError("offline")); + fetchMock.mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()).mockRejectedValueOnce(new TypeError("offline")); const { handlers } = setupPlugin({ config: { retryCount: 0 }, @@ -758,7 +925,7 @@ describe("orbio-openclaw plugin", () => { }, ])("$title", async ({ status, payload, headers, expected }) => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(jsonResponse(payload, status, headers)); const { handlers } = setupPlugin({ config: { retryCount: 0 } }); @@ -768,7 +935,7 @@ describe("orbio-openclaw plugin", () => { it("handles non-json error payloads safely", async () => { fetchMock - .mockResolvedValueOnce(capabilitiesResponse()) + .mockResolvedValueOnce(capabilitiesResponse()).mockResolvedValueOnce(specResponse()).mockResolvedValueOnce(normalizedSpecResponse()) .mockResolvedValueOnce(textResponse("not-json", 400, { "X-Request-Id": "req-text" })); const { handlers } = setupPlugin();