diff --git a/eslint.config.js b/eslint.config.js index a06437e..7a41a5b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,8 @@ export default [ setTimeout: 'readonly', NodeJS: 'readonly', require: 'readonly', + fetch: 'readonly', + URLSearchParams: 'readonly', }, }, plugins: { @@ -50,6 +52,8 @@ export default [ __dirname: 'readonly', clearTimeout: 'readonly', setTimeout: 'readonly', + global: 'readonly', + fetch: 'readonly', }, }, plugins: { diff --git a/src/__tests__/branches.test.ts b/src/__tests__/branches.test.ts index 71903aa..e9cbfa6 100644 --- a/src/__tests__/branches.test.ts +++ b/src/__tests__/branches.test.ts @@ -6,6 +6,22 @@ import { } from "../shared/elevenlabs-api"; import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js"; +const TEST_CTX = { apiKey: "test-key", baseUrl: "https://api.test" }; + +const realFetch = global.fetch; +afterEach(() => { + global.fetch = realFetch; +}); + +function mockFetch(response: Record): jest.Mock { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => response, + }); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + describe("Agent branch support", () => { function makeMockClient(opts: { branches?: Array<{ @@ -190,14 +206,14 @@ describe("Agent branch support", () => { }); describe("updateAgentApi with branchId", () => { - it("should not include branchId in payload when not provided", async () => { - const client = makeMockClient(); + it("should not include branch_id query param when not provided", async () => { + const fetchMock = mockFetch({ agent_id: "agent_123" }); const conversationConfig = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; await updateAgentApi( - client, + TEST_CTX, "agent_123", "Test Agent", conversationConfig, @@ -207,21 +223,18 @@ describe("Agent branch support", () => { "v1.0" ); - const [, payload] = ( - client.conversationalAi.agents.update as jest.Mock - ).mock.calls[0]; - - expect(payload.branchId).toBeUndefined(); + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.test/v1/convai/agents/agent_123"); }); - it("should include branchId in payload when provided", async () => { - const client = makeMockClient(); + it("should include branch_id query param when provided", async () => { + const fetchMock = mockFetch({ agent_id: "agent_123" }); const conversationConfig = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; await updateAgentApi( - client, + TEST_CTX, "agent_123", "Test Agent", conversationConfig, @@ -232,22 +245,20 @@ describe("Agent branch support", () => { "agtbrch_feat456" ); - const [agentId, payload] = ( - client.conversationalAi.agents.update as jest.Mock - ).mock.calls[0]; - - expect(agentId).toBe("agent_123"); - expect(payload.branchId).toBe("agtbrch_feat456"); + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe( + "https://api.test/v1/convai/agents/agent_123?branch_id=agtbrch_feat456" + ); }); it("should return branchId from API response", async () => { - const client = makeMockClient(); + mockFetch({ agent_id: "agent_123", branch_id: "agtbrch_feat" }); const conversationConfig = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; const result = await updateAgentApi( - client, + TEST_CTX, "agent_123", "Test Agent", conversationConfig, diff --git a/src/__tests__/casing.test.ts b/src/__tests__/casing.test.ts index 091123b..1fa1191 100644 --- a/src/__tests__/casing.test.ts +++ b/src/__tests__/casing.test.ts @@ -8,10 +8,28 @@ import { } from "../shared/elevenlabs-api"; import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js"; +const TEST_CTX = { apiKey: "test-key", baseUrl: "https://api.test" }; + +const realFetch = global.fetch; +afterEach(() => { + global.fetch = realFetch; +}); + +function mockFetch(response: Record = { agent_id: "agent_123" }): jest.Mock { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => response, + }); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +function sentBody(fetchMock: jest.Mock): Record { + return JSON.parse(fetchMock.mock.calls[0][1].body as string); +} + describe("Key casing normalization", () => { function makeMockClient() { - const create = jest.fn().mockResolvedValue({ agentId: "agent_123" }); - const update = jest.fn().mockResolvedValue({ agentId: "agent_123" }); const get = jest.fn().mockResolvedValue({ agentId: "agent_123", name: "Test Agent", @@ -34,13 +52,13 @@ describe("Key casing normalization", () => { return { conversationalAi: { - agents: { create, update, get }, + agents: { get }, }, } as unknown as ElevenLabsClient; } - it("createAgentApi camelizes outbound conversation_config and platform_settings", async () => { - const client = makeMockClient(); + it("createAgentApi sends conversation_config and platform_settings as snake_case wire JSON", async () => { + const fetchMock = mockFetch(); const conversation_config = { conversation: { client_events: ["audio", "interruption"], @@ -52,7 +70,7 @@ describe("Key casing normalization", () => { } as unknown as Record; await createAgentApi( - client, + TEST_CTX, "Name", conversation_config, platform_settings, @@ -60,31 +78,29 @@ describe("Key casing normalization", () => { ["prod"] ); - expect(client.conversationalAi.agents.create).toHaveBeenCalledTimes(1); - const [, payload] = [ - (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0] - .name, - (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0], - ]; + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.test/v1/convai/agents/create"); + expect(init.method).toBe("POST"); - expect(payload).toEqual( + expect(sentBody(fetchMock)).toEqual( expect.objectContaining({ name: "Name", - conversationConfig: expect.objectContaining({ + conversation_config: expect.objectContaining({ conversation: expect.objectContaining({ - clientEvents: ["audio", "interruption"], + client_events: ["audio", "interruption"], }), }), - platformSettings: expect.objectContaining({ - widget: expect.objectContaining({ textInputEnabled: true }), + platform_settings: expect.objectContaining({ + widget: expect.objectContaining({ text_input_enabled: true }), }), tags: ["prod"], }) ); }); - it("updateAgentApi camelizes outbound conversation_config", async () => { - const client = makeMockClient(); + it("updateAgentApi sends conversation_config as snake_case wire JSON", async () => { + const fetchMock = mockFetch(); const conversation_config = { conversation: { client_events: ["audio", "agent_response"], @@ -92,7 +108,7 @@ describe("Key casing normalization", () => { } as unknown as Record; await updateAgentApi( - client, + TEST_CTX, "agent_123", "Name", conversation_config, @@ -101,17 +117,16 @@ describe("Key casing normalization", () => { ["prod"] ); - expect(client.conversationalAi.agents.update).toHaveBeenCalledTimes(1); - const [agentId, payload] = ( - client.conversationalAi.agents.update as jest.Mock - ).mock.calls[0]; - expect(agentId).toBe("agent_123"); - expect(payload).toEqual( + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.test/v1/convai/agents/agent_123"); + expect(init.method).toBe("PATCH"); + expect(sentBody(fetchMock)).toEqual( expect.objectContaining({ name: "Name", - conversationConfig: expect.objectContaining({ + conversation_config: expect.objectContaining({ conversation: expect.objectContaining({ - clientEvents: ["audio", "agent_response"], + client_events: ["audio", "agent_response"], }), }), tags: ["prod"], @@ -119,6 +134,22 @@ describe("Key casing normalization", () => { ); }); + it("createAgentApi normalizes hand-written camelCase keys to snake_case on the wire", async () => { + const fetchMock = mockFetch(); + const conversation_config = { + conversation: { + clientEvents: ["audio"], + }, + agent: { prompt: { prompt: "hi", temperature: 0 } }, + } as unknown as Record; + + await createAgentApi(TEST_CTX, "Name", conversation_config, undefined, undefined, []); + + const body = sentBody(fetchMock); + expect(body.conversation_config.conversation).toHaveProperty("client_events"); + expect(body.conversation_config.conversation).not.toHaveProperty("clientEvents"); + }); + it("getAgentApi snake_cases inbound response for writing to disk", async () => { const client = makeMockClient(); const response = await getAgentApi(client, "agent_123"); @@ -143,7 +174,7 @@ describe("Key casing normalization", () => { }); it("createAgentApi removes deprecated 'tools' field when 'tool_ids' is present", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); const conversation_config = { conversation: { client_events: ["audio"], @@ -161,7 +192,7 @@ describe("Key casing normalization", () => { } as unknown as Record; await createAgentApi( - client, + TEST_CTX, "Agent with Tools", conversation_config, undefined, @@ -169,17 +200,16 @@ describe("Key casing normalization", () => { [] ); - expect(client.conversationalAi.agents.create).toHaveBeenCalledTimes(1); - const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; + const body = sentBody(fetchMock); - // Verify that 'tools' field is removed but 'toolIds' is present - expect(payload.conversationConfig.agent.prompt).not.toHaveProperty("tools"); - expect(payload.conversationConfig.agent.prompt).toHaveProperty("toolIds"); - expect(payload.conversationConfig.agent.prompt.toolIds).toEqual(["tool_123", "tool_456"]); + // Verify that 'tools' field is removed but 'tool_ids' is present + expect(body.conversation_config.agent.prompt).not.toHaveProperty("tools"); + expect(body.conversation_config.agent.prompt).toHaveProperty("tool_ids"); + expect(body.conversation_config.agent.prompt.tool_ids).toEqual(["tool_123", "tool_456"]); }); it("updateAgentApi removes deprecated 'tools' field when 'tool_ids' is present", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); const conversation_config = { agent: { prompt: { @@ -193,7 +223,7 @@ describe("Key casing normalization", () => { } as unknown as Record; await updateAgentApi( - client, + TEST_CTX, "agent_123", "Updated Agent", conversation_config, @@ -202,17 +232,16 @@ describe("Key casing normalization", () => { [] ); - expect(client.conversationalAi.agents.update).toHaveBeenCalledTimes(1); - const [, payload] = (client.conversationalAi.agents.update as jest.Mock).mock.calls[0]; + const body = sentBody(fetchMock); - // Verify that 'tools' field is removed but 'toolIds' is present - expect(payload.conversationConfig.agent.prompt).not.toHaveProperty("tools"); - expect(payload.conversationConfig.agent.prompt).toHaveProperty("toolIds"); - expect(payload.conversationConfig.agent.prompt.toolIds).toEqual(["tool_789"]); + // Verify that 'tools' field is removed but 'tool_ids' is present + expect(body.conversation_config.agent.prompt).not.toHaveProperty("tools"); + expect(body.conversation_config.agent.prompt).toHaveProperty("tool_ids"); + expect(body.conversation_config.agent.prompt.tool_ids).toEqual(["tool_789"]); }); - it("createAgentApi camelizes workflow edge conditions (forward_condition, backward_condition)", async () => { - const client = makeMockClient(); + it("createAgentApi sends workflow edge conditions unchanged on the wire", async () => { + const fetchMock = mockFetch(); const conversation_config = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; @@ -238,7 +267,7 @@ describe("Key casing normalization", () => { }; await createAgentApi( - client, + TEST_CTX, "Workflow Agent", conversation_config, undefined, @@ -246,25 +275,14 @@ describe("Key casing normalization", () => { [] ); - expect(client.conversationalAi.agents.create).toHaveBeenCalledTimes(1); - const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; + const body = sentBody(fetchMock); - // Verify workflow edge identifier keys are preserved, but schema fields within are camel-cased - expect(payload.workflow).toBeDefined(); - expect(payload.workflow.edges.edge_start_to_agent).toEqual({ - source: "start_node", - target: "agent_node", - forwardCondition: { type: "unconditional" } - }); - expect(payload.workflow.edges.edge_agent_to_end).toEqual({ - source: "agent_node", - target: "end_node", - backwardCondition: { type: "result", resultKey: "success" } - }); + // A pulled workflow must round-trip through push byte-for-byte + expect(body.workflow).toEqual(workflow); }); - it("updateAgentApi camelizes workflow edge conditions (forward_condition, backward_condition)", async () => { - const client = makeMockClient(); + it("updateAgentApi sends workflow edge conditions unchanged on the wire", async () => { + const fetchMock = mockFetch(); const conversation_config = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; @@ -285,7 +303,7 @@ describe("Key casing normalization", () => { }; await updateAgentApi( - client, + TEST_CTX, "agent_123", "Workflow Agent", conversation_config, @@ -294,20 +312,12 @@ describe("Key casing normalization", () => { [] ); - expect(client.conversationalAi.agents.update).toHaveBeenCalledTimes(1); - const [, payload] = (client.conversationalAi.agents.update as jest.Mock).mock.calls[0]; - - // Verify workflow edge identifier keys are preserved, but schema fields within are camel-cased - expect(payload.workflow).toBeDefined(); - expect(payload.workflow.edges.edge_start_to_agent).toEqual({ - source: "start_node", - target: "agent_node", - forwardCondition: { type: "llm", description: "When user asks for help" } - }); + const body = sentBody(fetchMock); + expect(body.workflow).toEqual(workflow); }); it("createAgentApi preserves data_collection child keys (user-defined identifiers)", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); const conversation_config = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; @@ -320,7 +330,7 @@ describe("Key casing normalization", () => { } as unknown as Record; await createAgentApi( - client, + TEST_CTX, "Agent with data_collection", conversation_config, platform_settings, @@ -328,23 +338,21 @@ describe("Key casing normalization", () => { [] ); - const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; + const body = sentBody(fetchMock); - // data_collection top-level key is camelized to dataCollection (envelope convention) - expect(payload.platformSettings).toHaveProperty("dataCollection"); - // Children are user-defined identifiers — must be preserved as-is (snake_case stays snake_case) - expect(payload.platformSettings.dataCollection).toHaveProperty("need_callback"); - expect(payload.platformSettings.dataCollection).toHaveProperty("call_end_reason"); - expect(payload.platformSettings.dataCollection).toHaveProperty("human_reached"); - // Leaf values under each identifier — nested schema fields like 'type'/'description' stay as-is (no underscores to convert) - expect(payload.platformSettings.dataCollection.need_callback).toEqual({ + expect(body.platform_settings).toHaveProperty("data_collection"); + // Children are user-defined identifiers — must be preserved as-is + expect(body.platform_settings.data_collection).toHaveProperty("need_callback"); + expect(body.platform_settings.data_collection).toHaveProperty("call_end_reason"); + expect(body.platform_settings.data_collection).toHaveProperty("human_reached"); + expect(body.platform_settings.data_collection.need_callback).toEqual({ type: "boolean", description: "Whether to call back", }); }); it("updateAgentApi preserves data_collection child keys (user-defined identifiers)", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); const conversation_config = { agent: { prompt: { prompt: "updated", temperature: 0 } }, } as unknown as Record; @@ -355,7 +363,7 @@ describe("Key casing normalization", () => { } as unknown as Record; await updateAgentApi( - client, + TEST_CTX, "agent_123", "Updated", conversation_config, @@ -364,10 +372,10 @@ describe("Key casing normalization", () => { [] ); - const [, payload] = (client.conversationalAi.agents.update as jest.Mock).mock.calls[0]; + const body = sentBody(fetchMock); - expect(payload.platformSettings).toHaveProperty("dataCollection"); - expect(payload.platformSettings.dataCollection).toHaveProperty("need_callback"); + expect(body.platform_settings).toHaveProperty("data_collection"); + expect(body.platform_settings.data_collection).toHaveProperty("need_callback"); }); it("getAgentApi preserves data_collection child keys on inbound snake_case conversion", async () => { @@ -428,7 +436,7 @@ describe("Key casing normalization", () => { }); it("createAgentApi preserves 'tools' field when 'tool_ids' is not present", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); const conversation_config = { agent: { prompt: { @@ -441,7 +449,7 @@ describe("Key casing normalization", () => { } as unknown as Record; await createAgentApi( - client, + TEST_CTX, "Agent with Legacy Tools", conversation_config, undefined, @@ -449,11 +457,11 @@ describe("Key casing normalization", () => { [] ); - const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; + const body = sentBody(fetchMock); // When tool_ids is not present, tools should be preserved - expect(payload.conversationConfig.agent.prompt).toHaveProperty("tools"); - expect(payload.conversationConfig.agent.prompt.tools).toHaveLength(1); + expect(body.conversation_config.agent.prompt).toHaveProperty("tools"); + expect(body.conversation_config.agent.prompt.tools).toHaveLength(1); }); function makeToolsMockClient() { @@ -485,8 +493,8 @@ describe("Key casing normalization", () => { } as unknown as ElevenLabsClient; } - it("createAgentApi camelizes the placeholders wrapper but preserves placeholder names", async () => { - const client = makeMockClient(); + it("createAgentApi keeps the placeholders wrapper snake_case and preserves placeholder names", async () => { + const fetchMock = mockFetch(); const conversation_config = { agent: { prompt: { prompt: "hi", temperature: 0 }, @@ -500,7 +508,7 @@ describe("Key casing normalization", () => { } as unknown as Record; await createAgentApi( - client, + TEST_CTX, "Agent with placeholders", conversation_config, undefined, @@ -508,21 +516,19 @@ describe("Key casing normalization", () => { [] ); - const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; - const dynamicVariables = payload.conversationConfig.agent.dynamicVariables; + const body = sentBody(fetchMock); + const dynamicVariables = body.conversation_config.agent.dynamic_variables; - // The wrapper is a schema field: it must be camelized or the SDK strips it from the request - expect(dynamicVariables).toHaveProperty("dynamicVariablePlaceholders"); - expect(dynamicVariables).not.toHaveProperty("dynamic_variable_placeholders"); + expect(dynamicVariables).toHaveProperty("dynamic_variable_placeholders"); // Placeholder names are user-defined identifiers, preserved as-is - expect(dynamicVariables.dynamicVariablePlaceholders).toEqual({ + expect(dynamicVariables.dynamic_variable_placeholders).toEqual({ transaction_id: "txn_default", verified: false, }); }); - it("updateAgentApi camelizes the placeholders wrapper but preserves placeholder names", async () => { - const client = makeMockClient(); + it("updateAgentApi keeps the placeholders wrapper snake_case and preserves placeholder names", async () => { + const fetchMock = mockFetch(); const conversation_config = { agent: { prompt: { prompt: "hi", temperature: 0 }, @@ -536,7 +542,7 @@ describe("Key casing normalization", () => { } as unknown as Record; await updateAgentApi( - client, + TEST_CTX, "agent_123", "Updated", conversation_config, @@ -545,12 +551,11 @@ describe("Key casing normalization", () => { [] ); - const [, payload] = (client.conversationalAi.agents.update as jest.Mock).mock.calls[0]; - const dynamicVariables = payload.conversationConfig.agent.dynamicVariables; + const body = sentBody(fetchMock); + const dynamicVariables = body.conversation_config.agent.dynamic_variables; - expect(dynamicVariables).toHaveProperty("dynamicVariablePlaceholders"); - expect(dynamicVariables).not.toHaveProperty("dynamic_variable_placeholders"); - expect(dynamicVariables.dynamicVariablePlaceholders).toEqual({ + expect(dynamicVariables).toHaveProperty("dynamic_variable_placeholders"); + expect(dynamicVariables.dynamic_variable_placeholders).toEqual({ transaction_id: "txn_default", user_name: "Jan", }); diff --git a/src/__tests__/versioning.test.ts b/src/__tests__/versioning.test.ts index 43c74bc..7274579 100644 --- a/src/__tests__/versioning.test.ts +++ b/src/__tests__/versioning.test.ts @@ -1,18 +1,32 @@ import { updateAgentApi, getAgentApi } from "../shared/elevenlabs-api"; import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js"; +const TEST_CTX = { apiKey: "test-key", baseUrl: "https://api.test" }; + +const realFetch = global.fetch; +afterEach(() => { + global.fetch = realFetch; +}); + +function mockFetch(response: Record): jest.Mock { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => response, + }); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +function sentBody(fetchMock: jest.Mock): Record { + return JSON.parse(fetchMock.mock.calls[0][1].body as string); +} + describe("Agent versioning and branch support", () => { function makeMockClient(opts: { versionId?: string; branchId?: string; mainBranchId?: string; } = {}) { - const create = jest.fn().mockResolvedValue({ agentId: "agent_ver_123" }); - const update = jest.fn().mockResolvedValue({ - agentId: "agent_ver_123", - versionId: opts.versionId ?? "ver_abc", - branchId: opts.branchId ?? "branch_main", - }); const get = jest.fn().mockResolvedValue({ agentId: "agent_ver_123", name: "Test Agent", @@ -28,20 +42,20 @@ describe("Agent versioning and branch support", () => { return { conversationalAi: { - agents: { create, update, get }, + agents: { get }, }, } as unknown as ElevenLabsClient; } describe("updateAgentApi", () => { it("should pass versionDescription to the API", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch({ agent_id: "agent_ver_123" }); const conversationConfig = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; await updateAgentApi( - client, + TEST_CTX, "agent_ver_123", "Test Agent", conversationConfig, @@ -51,27 +65,24 @@ describe("Agent versioning and branch support", () => { "release v1.0" ); - expect(client.conversationalAi.agents.update).toHaveBeenCalledTimes(1); - const [agentId, payload] = ( - client.conversationalAi.agents.update as jest.Mock - ).mock.calls[0]; - - expect(agentId).toBe("agent_ver_123"); - expect(payload).toEqual( + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.test/v1/convai/agents/agent_ver_123"); + expect(sentBody(fetchMock)).toEqual( expect.objectContaining({ - versionDescription: "release v1.0", + version_description: "release v1.0", }) ); }); it("should not include versionDescription when not provided", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch({ agent_id: "agent_ver_123" }); const conversationConfig = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; await updateAgentApi( - client, + TEST_CTX, "agent_ver_123", "Test Agent", conversationConfig, @@ -80,24 +91,21 @@ describe("Agent versioning and branch support", () => { [] ); - const [, payload] = ( - client.conversationalAi.agents.update as jest.Mock - ).mock.calls[0]; - - expect(payload.versionDescription).toBeUndefined(); + expect(sentBody(fetchMock)).not.toHaveProperty("version_description"); }); it("should return versionId and branchId from API response", async () => { - const client = makeMockClient({ - versionId: "ver_xyz", - branchId: "branch_feat", + mockFetch({ + agent_id: "agent_ver_123", + version_id: "ver_xyz", + branch_id: "branch_feat", }); const conversationConfig = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; const result = await updateAgentApi( - client, + TEST_CTX, "agent_ver_123", "Test Agent", conversationConfig, @@ -115,18 +123,14 @@ describe("Agent versioning and branch support", () => { }); it("should handle missing versionId/branchId in response", async () => { - const client = makeMockClient(); - // Override update to return response without version fields - (client.conversationalAi.agents.update as jest.Mock).mockResolvedValue({ - agentId: "agent_ver_123", - }); + mockFetch({ agent_id: "agent_ver_123" }); const conversationConfig = { agent: { prompt: { prompt: "hi", temperature: 0 } }, } as unknown as Record; const result = await updateAgentApi( - client, + TEST_CTX, "agent_ver_123", "Test Agent", conversationConfig diff --git a/src/__tests__/workflow.test.ts b/src/__tests__/workflow.test.ts index ee94d95..8af4bc8 100644 --- a/src/__tests__/workflow.test.ts +++ b/src/__tests__/workflow.test.ts @@ -1,6 +1,26 @@ import { createAgentApi, updateAgentApi, getAgentApi } from "../shared/elevenlabs-api"; import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js"; +const TEST_CTX = { apiKey: "test-key", baseUrl: "https://api.test" }; + +const realFetch = global.fetch; +afterEach(() => { + global.fetch = realFetch; +}); + +function mockFetch(response: Record = { agent_id: "agent_workflow_123" }): jest.Mock { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => response, + }); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +function sentBody(fetchMock: jest.Mock): Record { + return JSON.parse(fetchMock.mock.calls[0][1].body as string); +} + describe("Workflow support in agents", () => { function makeMockClient(includeWorkflow: boolean = false) { const mockWorkflow = includeWorkflow ? { @@ -30,8 +50,6 @@ describe("Workflow support in agents", () => { } } : undefined; - const create = jest.fn().mockResolvedValue({ agentId: "agent_workflow_123" }); - const update = jest.fn().mockResolvedValue({ agentId: "agent_workflow_123" }); const get = jest.fn().mockResolvedValue({ agentId: "agent_workflow_123", name: "Test Agent with Workflow", @@ -55,14 +73,14 @@ describe("Workflow support in agents", () => { return { conversationalAi: { - agents: { create, update, get }, + agents: { get }, }, } as unknown as ElevenLabsClient; } describe("createAgentApi", () => { it("should send workflow when provided", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); const conversation_config = { conversation: { client_events: ["audio"], @@ -81,7 +99,7 @@ describe("Workflow support in agents", () => { }; await createAgentApi( - client, + TEST_CTX, "Agent with Workflow", conversation_config, undefined, @@ -89,30 +107,20 @@ describe("Workflow support in agents", () => { ["workflow"] ); - expect(client.conversationalAi.agents.create).toHaveBeenCalledTimes(1); - const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = sentBody(fetchMock); - // Workflow node/edge identifier keys should be preserved (not camel-cased) - // Only schema fields within nodes/edges should be converted - expect(payload).toEqual( + expect(body).toEqual( expect.objectContaining({ name: "Agent with Workflow", - workflow: expect.objectContaining({ - nodes: expect.objectContaining({ - start: expect.any(Object), // "start" has no underscore, stays as-is - end: expect.any(Object), // "end" has no underscore, stays as-is - }), - edges: expect.objectContaining({ - edge_1: expect.any(Object), // edge_1 preserved as identifier - }), - }), + workflow, tags: ["workflow"], }) ); }); it("should handle undefined workflow gracefully", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); const conversation_config = { conversation: { client_events: ["audio"], @@ -121,7 +129,7 @@ describe("Workflow support in agents", () => { } as unknown as Record; await createAgentApi( - client, + TEST_CTX, "Agent without Workflow", conversation_config, undefined, @@ -129,21 +137,17 @@ describe("Workflow support in agents", () => { [] ); - expect(client.conversationalAi.agents.create).toHaveBeenCalledTimes(1); - const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = sentBody(fetchMock); - expect(payload).toEqual( - expect.objectContaining({ - name: "Agent without Workflow", - workflow: undefined, - }) - ); + expect(body.name).toBe("Agent without Workflow"); + expect(body).not.toHaveProperty("workflow"); }); }); describe("updateAgentApi", () => { it("should send workflow when updating an agent", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); const conversation_config = { conversation: { client_events: ["audio"], @@ -161,7 +165,7 @@ describe("Workflow support in agents", () => { }; await updateAgentApi( - client, + TEST_CTX, "agent_workflow_123", "Updated Agent", conversation_config, @@ -170,29 +174,22 @@ describe("Workflow support in agents", () => { ["updated"] ); - expect(client.conversationalAi.agents.update).toHaveBeenCalledTimes(1); - const [agentId, payload] = ( - client.conversationalAi.agents.update as jest.Mock - ).mock.calls[0]; + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.test/v1/convai/agents/agent_workflow_123"); + expect(init.method).toBe("PATCH"); - expect(agentId).toBe("agent_workflow_123"); - // Workflow node/edge identifier keys should be preserved (not camel-cased) - expect(payload).toEqual( + expect(sentBody(fetchMock)).toEqual( expect.objectContaining({ name: "Updated Agent", - workflow: expect.objectContaining({ - nodes: expect.objectContaining({ - updated_start: expect.any(Object), // preserved as identifier - updated_end: expect.any(Object), // preserved as identifier - }), - }), + workflow, tags: ["updated"], }) ); }); it("should allow clearing workflow by passing undefined", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); const conversation_config = { conversation: { client_events: ["audio"], @@ -200,7 +197,7 @@ describe("Workflow support in agents", () => { } as unknown as Record; await updateAgentApi( - client, + TEST_CTX, "agent_workflow_123", "Agent Workflow Cleared", conversation_config, @@ -209,16 +206,59 @@ describe("Workflow support in agents", () => { [] ); - expect(client.conversationalAi.agents.update).toHaveBeenCalledTimes(1); - const [, payload] = ( - client.conversationalAi.agents.update as jest.Mock - ).mock.calls[0]; + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(sentBody(fetchMock)).not.toHaveProperty("workflow"); + }); - expect(payload).toEqual( - expect.objectContaining({ - workflow: undefined, - }) + // Regression: workflows with expression-type edge conditions containing `llm` + // or `null_literal` AST nodes must round-trip through push unchanged. The + // SDK's generated serializers reject these (missing "value" key / unknown + // union member), which is why push sends raw JSON. + it("round-trips expression conditions with llm and null_literal nodes verbatim", async () => { + const fetchMock = mockFetch(); + + const workflow = { + edges: { + edge_01: { + source: "node_a", + target: "node_b", + forward_condition: { + type: "expression", + expression: { + type: "and_operator", + children: [ + { + type: "neq_operator", + left: { type: "dynamic_variable", name: "system__caller_id" }, + right: { type: "null_literal" } + }, + { + type: "llm", + value_schema: { + type: "boolean", + description: "customer expressed intention to book an appointment" + }, + prompt: "customer expressed intention to book an appointment" + } + ] + } + } + } + }, + nodes: {} + }; + + await updateAgentApi( + TEST_CTX, + "agent_workflow_123", + "Expression Agent", + { agent: { prompt: { prompt: "hi" } } } as unknown as Record, + undefined, + workflow, + [] ); + + expect(sentBody(fetchMock).workflow).toEqual(workflow); }); }); @@ -269,7 +309,7 @@ describe("Workflow support in agents", () => { describe("Workflow persistence in pull/push flow", () => { it("should preserve complex workflow structures", async () => { - const client = makeMockClient(); + const fetchMock = mockFetch(); // Complex workflow with multiple node types const complexWorkflow = { @@ -314,7 +354,7 @@ describe("Workflow support in agents", () => { }; await createAgentApi( - client, + TEST_CTX, "Complex Workflow Agent", { agent: { prompt: { prompt: "test", temperature: 0 } } } as unknown as Record, undefined, @@ -322,21 +362,11 @@ describe("Workflow support in agents", () => { ["complex"] ); - const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; - - // Workflow node/edge identifier keys should be preserved (not camel-cased) - expect(payload.workflow.nodes).toHaveProperty("start_1"); // preserved as identifier - expect(payload.workflow.nodes).toHaveProperty("agent_1"); // preserved as identifier - expect(payload.workflow.nodes).toHaveProperty("tool_1"); // preserved as identifier - expect(payload.workflow.nodes).toHaveProperty("end_1"); // preserved as identifier - expect(payload.workflow.edges).toHaveProperty("edge_start_to_agent"); // preserved as identifier - expect(payload.workflow.edges).toHaveProperty("edge_agent_to_tool"); // preserved as identifier - expect(payload.workflow.edges).toHaveProperty("edge_tool_to_end"); // preserved as identifier - - // Verify nested schema properties ARE still converted to camelCase - expect(payload.workflow.nodes.start_1.config).toHaveProperty("initialMessage"); // initial_message → initialMessage - expect(payload.workflow.nodes.agent_1).toHaveProperty("agentId"); // agent_id → agentId - expect(payload.workflow.nodes.tool_1).toHaveProperty("toolId"); // tool_id → toolId + const body = sentBody(fetchMock); + + // The pulled snake_case workflow must reach the wire unchanged: node/edge + // identifier keys AND schema fields (agent_id, tool_id, initial_message) + expect(body.workflow).toEqual(complexWorkflow); }); }); }); diff --git a/src/agents/commands/add.ts b/src/agents/commands/add.ts index 617db98..781db50 100644 --- a/src/agents/commands/add.ts +++ b/src/agents/commands/add.ts @@ -6,7 +6,7 @@ import fs from 'fs-extra'; import AddAgentView from '../ui/AddAgentView.js'; import { readConfig, writeConfig, generateUniqueFilename } from '../../shared/utils.js'; import { getTemplateByName, AgentConfig } from '../templates.js'; -import { getElevenLabsClient, createAgentApi } from '../../shared/elevenlabs-api.js'; +import { getApiContext, createAgentApi } from '../../shared/elevenlabs-api.js'; const AGENTS_CONFIG_FILE = "agents.json"; @@ -111,7 +111,7 @@ export function createAddCommand(): Command { // Create agent in ElevenLabs first to get ID console.log(`Creating agent '${agentName}' in ElevenLabs...`); - const client = await getElevenLabsClient(); + const apiCtx = await getApiContext(); // Extract config components const conversationConfig = agentConfig.conversation_config || {}; @@ -121,7 +121,7 @@ export function createAddCommand(): Command { // Create new agent const agentId = await createAgentApi( - client, + apiCtx, agentName, conversationConfig, platformSettings, diff --git a/src/agents/commands/push-impl.ts b/src/agents/commands/push-impl.ts index d1c1c0f..740c1d9 100644 --- a/src/agents/commands/push-impl.ts +++ b/src/agents/commands/push-impl.ts @@ -1,7 +1,7 @@ import path from 'path'; import fs from 'fs-extra'; import { readConfig, writeConfig } from '../../shared/utils.js'; -import { getElevenLabsClient, createAgentApi, updateAgentApi, resolveBranchId } from '../../shared/elevenlabs-api.js'; +import { getElevenLabsClient, getApiContext, createAgentApi, updateAgentApi, resolveBranchId } from '../../shared/elevenlabs-api.js'; import { verifyAgentPush } from '../../shared/verify.js'; import { AgentConfig } from '../templates.js'; @@ -92,8 +92,10 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string, vers // Initialize ElevenLabs client let client; + let apiCtx; try { client = await getElevenLabsClient(); + apiCtx = await getApiContext(); } catch (error) { console.log(`Error: ${error}`); console.log(`Skipping agent ${agentDefName} - not configured`); @@ -120,7 +122,7 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string, vers if (!currentAgentId) { // Create new agent const newAgentId = await createAgentApi( - client, + apiCtx, agentDisplayName, conversationConfig, platformSettings, @@ -141,7 +143,7 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string, vers } else { // Update existing agent const result = await updateAgentApi( - client, + apiCtx, currentAgentId, agentDisplayName, conversationConfig, @@ -183,7 +185,7 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string, vers console.log(` Pushing branch '${branchName}'...`); const branchResult = await updateAgentApi( - client, + apiCtx, currentAgentId, branchConfig.name, branchConversationConfig, diff --git a/src/agents/ui/AddAgentView.tsx b/src/agents/ui/AddAgentView.tsx index 24dcbb3..9b38f2e 100644 --- a/src/agents/ui/AddAgentView.tsx +++ b/src/agents/ui/AddAgentView.tsx @@ -8,7 +8,7 @@ import theme from '../../ui/themes/elevenlabs.js'; import { getTemplateByName, getTemplateOptions } from '../templates.js'; import { writeConfig, generateUniqueFilename } from '../../shared/utils.js'; import { createAgentApi } from '../../shared/elevenlabs-api.js'; -import { getElevenLabsClient } from '../../shared/elevenlabs-api.js'; +import { getApiContext } from '../../shared/elevenlabs-api.js'; import path from 'path'; import fs from 'fs-extra'; @@ -68,13 +68,13 @@ export const AddAgentView: React.FC = ({ // Step 2: Upload to ElevenLabs first to get ID setStatusMessage('Creating agent in ElevenLabs...'); - const client = await getElevenLabsClient(); + const apiCtx = await getApiContext(); const conversationConfig = agentConfig.conversation_config || {}; const platformSettings = agentConfig.platform_settings; const workflow = agentConfig.workflow; const tags = agentConfig.tags || []; const agentId = await createAgentApi( - client, + apiCtx, agentName, conversationConfig, platformSettings, diff --git a/src/agents/ui/PushView.tsx b/src/agents/ui/PushView.tsx index 6d5e118..563c546 100644 --- a/src/agents/ui/PushView.tsx +++ b/src/agents/ui/PushView.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Box, Text, useApp } from 'ink'; import App from '../../ui/App.js'; import theme from '../../ui/themes/elevenlabs.js'; -import { getElevenLabsClient, createAgentApi, updateAgentApi, resolveBranchId } from '../../shared/elevenlabs-api.js'; +import { getElevenLabsClient, getApiContext, createAgentApi, updateAgentApi, resolveBranchId } from '../../shared/elevenlabs-api.js'; import { readConfig, writeConfig } from '../../shared/utils.js'; import fs from 'fs-extra'; import path from 'path'; @@ -99,6 +99,7 @@ export const PushView: React.FC = ({ // Get ElevenLabs client const client = await getElevenLabsClient(); + const apiCtx = await getApiContext(); // Resolve branch if needed let branchId: string | undefined; @@ -116,7 +117,7 @@ export const PushView: React.FC = ({ if (!agentId) { // Create new agent const newAgentId = await createAgentApi( - client, + apiCtx, agentDisplayName, conversationConfig, platformSettings, @@ -146,7 +147,7 @@ export const PushView: React.FC = ({ } else { // Update existing agent const result = await updateAgentApi( - client, + apiCtx, agentId, agentDisplayName, conversationConfig, @@ -172,7 +173,7 @@ export const PushView: React.FC = ({ if (!(await fs.pathExists(branchDef.config))) continue; const branchConfig = await readConfig(branchDef.config); const branchResult = await updateAgentApi( - client, + apiCtx, agentId, branchConfig.name, branchConfig.conversation_config || {}, diff --git a/src/shared/elevenlabs-api.ts b/src/shared/elevenlabs-api.ts index 9cedd75..89af672 100644 --- a/src/shared/elevenlabs-api.ts +++ b/src/shared/elevenlabs-api.ts @@ -1,23 +1,8 @@ import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'; import { ElevenLabs } from '@elevenlabs/elevenlabs-js'; -import { - ConversationalConfig, - AgentPlatformSettingsRequestModel, - AgentWorkflowRequestModel -} from '@elevenlabs/elevenlabs-js/api'; import { getApiKey, loadConfig, Location } from './config.js'; import { toCamelCaseKeys, toSnakeCaseKeys } from './utils.js'; -// Type guard for conversational config -function isConversationalConfig(config: unknown): config is ConversationalConfig { - return typeof config === 'object' && config !== null; -} - -// Type guard for platform settings -function isPlatformSettings(settings: unknown): settings is AgentPlatformSettingsRequestModel { - return typeof settings === 'object' && settings !== null; -} - /** * Cleans conversation config before sending to API. * Removes the deprecated 'tools' field if 'tool_ids' is present to avoid API conflicts. @@ -80,7 +65,7 @@ export async function getElevenLabsClient(): Promise { const config = await loadConfig(); const baseURL = getApiBaseUrl(config.residency); - return new ElevenLabsClient({ + return new ElevenLabsClient({ apiKey, baseUrl: baseURL, headers: { @@ -89,10 +74,71 @@ export async function getElevenLabsClient(): Promise { }); } +/** + * Connection details for raw Convai API requests. + */ +export interface ApiContext { + apiKey: string; + baseUrl: string; +} + +/** + * Resolves the API key and base URL for raw Convai API requests. + * + * @throws {Error} If no API key is found + */ +export async function getApiContext(): Promise { + const apiKey = await getApiKey(); + if (!apiKey) { + throw new Error(`No API key found. Use 'elevenlabs auth login' to authenticate or set ELEVENLABS_API_KEY environment variable.`); + } + + const config = await loadConfig(); + return { apiKey, baseUrl: getApiBaseUrl(config.residency) }; +} + +/** + * Performs a raw JSON request against the Convai API. + * + * Agent create/update bodies deliberately bypass the SDK's generated + * serializers: they mirror the OpenAPI spec imperfectly for recursive union + * structures (e.g. workflow expression conditions with `llm` or `null_literal` + * nodes) and either reject or silently strip valid configs. Sending the + * snake_case config as-is guarantees a pulled config round-trips through push. + */ +async function convaiRequest( + ctx: ApiContext, + method: 'POST' | 'PATCH', + path: string, + body: Record, + queryParams?: Record +): Promise> { + const query = queryParams && Object.keys(queryParams).length > 0 + ? `?${new URLSearchParams(queryParams).toString()}` + : ''; + + const response = await fetch(`${ctx.baseUrl}${path}${query}`, { + method, + headers: { + 'xi-api-key': ctx.apiKey, + 'Content-Type': 'application/json', + 'X-Source': 'agents-cli' + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`${method} ${path} failed (${response.status}): ${errorBody}`); + } + + return await response.json() as Record; +} + /** * Creates a new agent using the ElevenLabs API. * - * @param client - An initialized ElevenLabs client + * @param ctx - API connection context from getApiContext() * @param name - The name of the agent * @param conversationConfigDict - A dictionary for ConversationalConfig * @param platformSettingsDict - An optional dictionary for AgentPlatformSettings @@ -101,42 +147,37 @@ export async function getElevenLabsClient(): Promise { * @returns Promise that resolves to the agent_id of the newly created agent */ export async function createAgentApi( - client: ElevenLabsClient, + ctx: ApiContext, name: string, conversationConfigDict: Record, platformSettingsDict?: Record, workflow?: unknown, tags?: string[] ): Promise { - if (!isConversationalConfig(conversationConfigDict)) { + if (typeof conversationConfigDict !== 'object' || conversationConfigDict === null) { throw new Error('Invalid conversation config provided'); } // Clean config to remove deprecated 'tools' if 'tool_ids' exists const cleanedConfig = cleanConversationConfigForApi(conversationConfigDict); - // Normalize to camelCase for API - const convConfig = toCamelCaseKeys(cleanedConfig) as ConversationalConfig; - const platformSettings = platformSettingsDict && isPlatformSettings(platformSettingsDict) ? toCamelCaseKeys(platformSettingsDict) as AgentPlatformSettingsRequestModel : undefined; - - // Normalize workflow to camelCase for API (same as conversationConfig and platformSettings) - const workflowConfig = workflow ? toCamelCaseKeys(workflow) as AgentWorkflowRequestModel : undefined; - - const response = await client.conversationalAi.agents.create({ + const body: Record = { name, - conversationConfig: convConfig, - platformSettings, - workflow: workflowConfig, - tags - }); + conversation_config: toSnakeCaseKeys(cleanedConfig) + }; + if (platformSettingsDict) body.platform_settings = toSnakeCaseKeys(platformSettingsDict); + if (workflow) body.workflow = toSnakeCaseKeys(workflow); + if (tags) body.tags = tags; + + const response = await convaiRequest(ctx, 'POST', '/v1/convai/agents/create', body); - return response.agentId; + return response.agent_id as string; } /** * Updates an existing agent using the ElevenLabs API. * - * @param client - An initialized ElevenLabs client + * @param ctx - API connection context from getApiContext() * @param agentId - The ID of the agent to update * @param name - Optional new name for the agent * @param conversationConfigDict - Optional new dictionary for ConversationalConfig @@ -146,7 +187,7 @@ export async function createAgentApi( * @returns Promise that resolves to the agent_id of the updated agent */ export async function updateAgentApi( - client: ElevenLabsClient, + ctx: ApiContext, agentId: string, name?: string, conversationConfigDict?: Record, @@ -159,25 +200,26 @@ export async function updateAgentApi( // Clean config to remove deprecated 'tools' if 'tool_ids' exists const cleanedConfig = conversationConfigDict ? cleanConversationConfigForApi(conversationConfigDict) : undefined; - const convConfig = cleanedConfig && isConversationalConfig(cleanedConfig) ? toCamelCaseKeys(cleanedConfig) as ConversationalConfig : undefined; - const platformSettings = platformSettingsDict && isPlatformSettings(platformSettingsDict) ? toCamelCaseKeys(platformSettingsDict) as AgentPlatformSettingsRequestModel : undefined; - // Normalize workflow to camelCase for API (same as conversationConfig and platformSettings) - const workflowConfig = workflow ? toCamelCaseKeys(workflow) as AgentWorkflowRequestModel : undefined; - - const response = await client.conversationalAi.agents.update(agentId, { - name, - conversationConfig: convConfig, - platformSettings, - workflow: workflowConfig, - tags, - versionDescription, - ...(branchId ? { branchId } : {}) - }); + const body: Record = {}; + if (name !== undefined) body.name = name; + if (cleanedConfig) body.conversation_config = toSnakeCaseKeys(cleanedConfig); + if (platformSettingsDict) body.platform_settings = toSnakeCaseKeys(platformSettingsDict); + if (workflow) body.workflow = toSnakeCaseKeys(workflow); + if (tags) body.tags = tags; + if (versionDescription !== undefined) body.version_description = versionDescription; + + const response = await convaiRequest( + ctx, + 'PATCH', + `/v1/convai/agents/${agentId}`, + body, + branchId ? { branch_id: branchId } : undefined + ); return { - agentId: response.agentId, - versionId: response.versionId, - branchId: response.branchId + agentId: response.agent_id as string, + versionId: response.version_id as string | undefined, + branchId: response.branch_id as string | undefined }; }