diff --git a/src/__tests__/add-commands-filename.integration.test.ts b/src/__tests__/add-commands-filename.integration.test.ts index 4f610ac..ade73a4 100644 --- a/src/__tests__/add-commands-filename.integration.test.ts +++ b/src/__tests__/add-commands-filename.integration.test.ts @@ -84,7 +84,7 @@ describe("Add Commands - Name-based Filenames", () => { const agentConfig = getTemplateByName(agentName, "default"); // Call the API - const agentId = await createAgentApi({} as never, agentName, {}, {}, []); + const agentId = await createAgentApi({} as never, agentName, {}, {}, undefined, []); // Generate config path using name (not ID) const configPath = await generateUniqueFilename( diff --git a/src/__tests__/casing.test.ts b/src/__tests__/casing.test.ts index 0e3b00d..d6dcfcd 100644 --- a/src/__tests__/casing.test.ts +++ b/src/__tests__/casing.test.ts @@ -49,6 +49,7 @@ describe("Key casing normalization", () => { "Name", conversation_config, platform_settings, + undefined, ["prod"] ); @@ -89,6 +90,7 @@ describe("Key casing normalization", () => { "Name", conversation_config, undefined, + undefined, ["prod"] ); diff --git a/src/__tests__/workflow.test.ts b/src/__tests__/workflow.test.ts new file mode 100644 index 0000000..ac3b59e --- /dev/null +++ b/src/__tests__/workflow.test.ts @@ -0,0 +1,335 @@ +import { createAgentApi, updateAgentApi, getAgentApi } from "../shared/elevenlabs-api"; +import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js"; + +describe("Workflow support in agents", () => { + function makeMockClient(includeWorkflow: boolean = false) { + const mockWorkflow = includeWorkflow ? { + nodes: { + "start_node": { + type: "start", + position: { x: 0, y: 0 } + }, + "agent_node": { + type: "agent", + position: { x: 100, y: 100 } + }, + "end_node": { + type: "end", + position: { x: 200, y: 200 } + } + }, + edges: { + "edge_1": { + from: "start_node", + to: "agent_node" + }, + "edge_2": { + from: "agent_node", + to: "end_node" + } + } + } : 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", + conversationConfig: { + conversation: { + clientEvents: ["audio"], + }, + agent: { + prompt: { + prompt: "Hello", + temperature: 0.5, + }, + }, + }, + platformSettings: { + widget: { textInputEnabled: true }, + }, + workflow: mockWorkflow, + tags: ["workflow-test"], + }); + + return { + conversationalAi: { + agents: { create, update, get }, + }, + } as unknown as ElevenLabsClient; + } + + describe("createAgentApi", () => { + it("should send workflow when provided", async () => { + const client = makeMockClient(); + const conversation_config = { + conversation: { + client_events: ["audio"], + }, + agent: { prompt: { prompt: "hi", temperature: 0 } }, + } as unknown as Record; + + const workflow = { + nodes: { + "start": { type: "start", position: { x: 0, y: 0 } }, + "end": { type: "end", position: { x: 100, y: 100 } } + }, + edges: { + "edge_1": { from: "start", to: "end" } + } + }; + + await createAgentApi( + client, + "Agent with Workflow", + conversation_config, + undefined, + workflow, + ["workflow"] + ); + + expect(client.conversationalAi.agents.create).toHaveBeenCalledTimes(1); + const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; + + expect(payload).toEqual( + expect.objectContaining({ + name: "Agent with Workflow", + workflow: expect.objectContaining({ + nodes: expect.objectContaining({ + start: expect.any(Object), + end: expect.any(Object), + }), + edges: expect.objectContaining({ + edge_1: expect.any(Object), + }), + }), + tags: ["workflow"], + }) + ); + }); + + it("should handle undefined workflow gracefully", async () => { + const client = makeMockClient(); + const conversation_config = { + conversation: { + client_events: ["audio"], + }, + agent: { prompt: { prompt: "hi", temperature: 0 } }, + } as unknown as Record; + + await createAgentApi( + client, + "Agent without Workflow", + conversation_config, + undefined, + undefined, + [] + ); + + expect(client.conversationalAi.agents.create).toHaveBeenCalledTimes(1); + const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; + + expect(payload).toEqual( + expect.objectContaining({ + name: "Agent without Workflow", + workflow: undefined, + }) + ); + }); + }); + + describe("updateAgentApi", () => { + it("should send workflow when updating an agent", async () => { + const client = makeMockClient(); + const conversation_config = { + conversation: { + client_events: ["audio"], + }, + } as unknown as Record; + + const workflow = { + nodes: { + "updated_start": { type: "start", position: { x: 10, y: 10 } }, + "updated_end": { type: "end", position: { x: 110, y: 110 } } + }, + edges: { + "updated_edge": { from: "updated_start", to: "updated_end" } + } + }; + + await updateAgentApi( + client, + "agent_workflow_123", + "Updated Agent", + conversation_config, + undefined, + workflow, + ["updated"] + ); + + expect(client.conversationalAi.agents.update).toHaveBeenCalledTimes(1); + const [agentId, payload] = ( + client.conversationalAi.agents.update as jest.Mock + ).mock.calls[0]; + + expect(agentId).toBe("agent_workflow_123"); + expect(payload).toEqual( + expect.objectContaining({ + name: "Updated Agent", + workflow: expect.objectContaining({ + nodes: expect.objectContaining({ + updated_start: expect.any(Object), + updated_end: expect.any(Object), + }), + }), + tags: ["updated"], + }) + ); + }); + + it("should allow clearing workflow by passing undefined", async () => { + const client = makeMockClient(); + const conversation_config = { + conversation: { + client_events: ["audio"], + }, + } as unknown as Record; + + await updateAgentApi( + client, + "agent_workflow_123", + "Agent Workflow Cleared", + conversation_config, + undefined, + undefined, + [] + ); + + expect(client.conversationalAi.agents.update).toHaveBeenCalledTimes(1); + const [, payload] = ( + client.conversationalAi.agents.update as jest.Mock + ).mock.calls[0]; + + expect(payload).toEqual( + expect.objectContaining({ + workflow: undefined, + }) + ); + }); + }); + + describe("getAgentApi", () => { + it("should return workflow when present in API response", async () => { + const client = makeMockClient(true); + const response = await getAgentApi(client, "agent_workflow_123"); + + expect(client.conversationalAi.agents.get).toHaveBeenCalledWith( + "agent_workflow_123" + ); + + expect(response).toEqual( + expect.objectContaining({ + agent_id: "agent_workflow_123", + workflow: expect.objectContaining({ + nodes: expect.any(Object), + edges: expect.any(Object), + }), + }) + ); + + // Verify workflow structure + const responseTyped = response as { workflow: { nodes: Record; edges: Record } }; + expect(responseTyped.workflow.nodes).toHaveProperty("start_node"); + expect(responseTyped.workflow.nodes).toHaveProperty("agent_node"); + expect(responseTyped.workflow.nodes).toHaveProperty("end_node"); + expect(responseTyped.workflow.edges).toHaveProperty("edge_1"); + expect(responseTyped.workflow.edges).toHaveProperty("edge_2"); + }); + + it("should handle agents without workflow", async () => { + const client = makeMockClient(false); + const response = await getAgentApi(client, "agent_workflow_123"); + + expect(client.conversationalAi.agents.get).toHaveBeenCalledWith( + "agent_workflow_123" + ); + + expect(response).toEqual( + expect.objectContaining({ + agent_id: "agent_workflow_123", + workflow: undefined, + }) + ); + }); + }); + + describe("Workflow persistence in pull/push flow", () => { + it("should preserve complex workflow structures", async () => { + const client = makeMockClient(); + + // Complex workflow with multiple node types + const complexWorkflow = { + nodes: { + "start_1": { + type: "start", + position: { x: 0, y: 0 }, + config: { initial_message: "Welcome" } + }, + "agent_1": { + type: "override_agent", + position: { x: 100, y: 50 }, + agent_id: "some_agent_id" + }, + "tool_1": { + type: "tool", + position: { x: 200, y: 100 }, + tool_id: "tool_123" + }, + "end_1": { + type: "end", + position: { x: 300, y: 150 } + } + }, + edges: { + "edge_start_to_agent": { + from: "start_1", + to: "agent_1", + condition: { type: "unconditional" } + }, + "edge_agent_to_tool": { + from: "agent_1", + to: "tool_1", + condition: { type: "llm", description: "When user asks for help" } + }, + "edge_tool_to_end": { + from: "tool_1", + to: "end_1", + condition: { type: "result", expected: "success" } + } + } + }; + + await createAgentApi( + client, + "Complex Workflow Agent", + { agent: { prompt: { prompt: "test", temperature: 0 } } } as unknown as Record, + undefined, + complexWorkflow, + ["complex"] + ); + + const payload = (client.conversationalAi.agents.create as jest.Mock).mock.calls[0][0]; + + // Verify complex workflow is preserved + expect(payload.workflow).toEqual(complexWorkflow); + expect(payload.workflow.nodes).toHaveProperty("start_1"); + expect(payload.workflow.nodes).toHaveProperty("agent_1"); + expect(payload.workflow.nodes).toHaveProperty("tool_1"); + expect(payload.workflow.nodes).toHaveProperty("end_1"); + expect(payload.workflow.edges).toHaveProperty("edge_start_to_agent"); + expect(payload.workflow.edges).toHaveProperty("edge_agent_to_tool"); + expect(payload.workflow.edges).toHaveProperty("edge_tool_to_end"); + }); + }); +}); diff --git a/src/agents/commands/add.ts b/src/agents/commands/add.ts index e2dd2b9..249229d 100644 --- a/src/agents/commands/add.ts +++ b/src/agents/commands/add.ts @@ -81,6 +81,7 @@ export function createAddCommand(): Command { // Extract config components const conversationConfig = agentConfig.conversation_config || {}; const platformSettings = agentConfig.platform_settings; + const workflow = agentConfig.workflow; const tags = agentConfig.tags || []; // Create new agent @@ -89,6 +90,7 @@ export function createAddCommand(): Command { name, conversationConfig, platformSettings, + workflow, tags ); diff --git a/src/agents/commands/pull-impl.ts b/src/agents/commands/pull-impl.ts index 1b15198..83861f8 100644 --- a/src/agents/commands/pull-impl.ts +++ b/src/agents/commands/pull-impl.ts @@ -175,11 +175,13 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: conversation_config: Record; platformSettings: Record; platform_settings: Record; + workflow?: unknown; tags: string[]; }; const conversationConfig = agentDetailsTyped.conversationConfig || agentDetailsTyped.conversation_config || {}; const platformSettings = agentDetailsTyped.platformSettings || agentDetailsTyped.platform_settings || {}; + const workflow = agentDetailsTyped.workflow; const tags = agentDetailsTyped.tags || []; // Create agent config structure (without agent_id - it goes in index file) @@ -190,6 +192,11 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: tags }; + // Only include workflow if it exists + if (workflow !== undefined && workflow !== null) { + agentConfig.workflow = workflow; + } + if (action === 'update' && existingEntry) { // Update existing entry - overwrite the config file const configFilePath = path.resolve(existingEntry.config); diff --git a/src/agents/commands/push-impl.ts b/src/agents/commands/push-impl.ts index 2b1c46c..43812ed 100644 --- a/src/agents/commands/push-impl.ts +++ b/src/agents/commands/push-impl.ts @@ -81,6 +81,7 @@ export async function pushAgents(dryRun: boolean = false): Promise { // Extract config components const conversationConfig = agentConfig.conversation_config || {}; const platformSettings = agentConfig.platform_settings; + const workflow = agentConfig.workflow; const tags = agentConfig.tags || []; const agentDisplayName = agentConfig.name; @@ -92,6 +93,7 @@ export async function pushAgents(dryRun: boolean = false): Promise { agentDisplayName, conversationConfig, platformSettings, + workflow, tags ); console.log(`Created agent ${agentDefName} (ID: ${newAgentId})`); @@ -107,6 +109,7 @@ export async function pushAgents(dryRun: boolean = false): Promise { agentDisplayName, conversationConfig, platformSettings, + workflow, tags ); console.log(`Updated agent ${agentDefName} (ID: ${agentId})`); diff --git a/src/agents/templates.ts b/src/agents/templates.ts index 26758bf..dcb3a56 100644 --- a/src/agents/templates.ts +++ b/src/agents/templates.ts @@ -54,6 +54,7 @@ export interface AgentConfig { }; [key: string]: unknown; }; + workflow?: unknown; tags: string[]; } diff --git a/src/agents/ui/AddAgentView.tsx b/src/agents/ui/AddAgentView.tsx index 785df9f..24dcbb3 100644 --- a/src/agents/ui/AddAgentView.tsx +++ b/src/agents/ui/AddAgentView.tsx @@ -71,12 +71,14 @@ export const AddAgentView: React.FC = ({ const client = await getElevenLabsClient(); const conversationConfig = agentConfig.conversation_config || {}; const platformSettings = agentConfig.platform_settings; + const workflow = agentConfig.workflow; const tags = agentConfig.tags || []; const agentId = await createAgentApi( client, agentName, conversationConfig, platformSettings, + workflow, tags ); diff --git a/src/agents/ui/PushView.tsx b/src/agents/ui/PushView.tsx index 682e221..32a0e07 100644 --- a/src/agents/ui/PushView.tsx +++ b/src/agents/ui/PushView.tsx @@ -99,6 +99,7 @@ export const PushView: React.FC = ({ // Extract config components const conversationConfig = agentConfig.conversation_config || {}; const platformSettings = agentConfig.platform_settings; + const workflow = agentConfig.workflow; const tags = agentConfig.tags || []; const agentDisplayName = agentConfig.name || 'Unnamed Agent'; @@ -109,6 +110,7 @@ export const PushView: React.FC = ({ agentDisplayName, conversationConfig, platformSettings, + workflow, tags ); @@ -139,6 +141,7 @@ export const PushView: React.FC = ({ agentDisplayName, conversationConfig, platformSettings, + workflow, tags ); diff --git a/src/shared/elevenlabs-api.ts b/src/shared/elevenlabs-api.ts index 86b5058..e8f47b6 100644 --- a/src/shared/elevenlabs-api.ts +++ b/src/shared/elevenlabs-api.ts @@ -59,11 +59,12 @@ export async function getElevenLabsClient(): Promise { /** * Creates a new agent using the ElevenLabs API. - * + * * @param client - An initialized ElevenLabs client * @param name - The name of the agent * @param conversationConfigDict - A dictionary for ConversationalConfig * @param platformSettingsDict - An optional dictionary for AgentPlatformSettings + * @param workflow - An optional workflow configuration * @param tags - An optional list of tags * @returns Promise that resolves to the agent_id of the newly created agent */ @@ -72,34 +73,37 @@ export async function createAgentApi( name: string, conversationConfigDict: Record, platformSettingsDict?: Record, + workflow?: unknown, tags?: string[] ): Promise { if (!isConversationalConfig(conversationConfigDict)) { throw new Error('Invalid conversation config provided'); } - + // Normalize to camelCase for API const convConfig = toCamelCaseKeys(conversationConfigDict) as ConversationalConfig; const platformSettings = platformSettingsDict && isPlatformSettings(platformSettingsDict) ? toCamelCaseKeys(platformSettingsDict) as AgentPlatformSettingsRequestModel : undefined; - + const response = await client.conversationalAi.agents.create({ name, conversationConfig: convConfig, platformSettings, + workflow, tags }); - + return response.agentId; } /** * Updates an existing agent using the ElevenLabs API. - * + * * @param client - An initialized ElevenLabs client * @param agentId - The ID of the agent to update * @param name - Optional new name for the agent * @param conversationConfigDict - Optional new dictionary for ConversationalConfig * @param platformSettingsDict - Optional new dictionary for AgentPlatformSettings + * @param workflow - Optional workflow configuration * @param tags - Optional new list of tags * @returns Promise that resolves to the agent_id of the updated agent */ @@ -109,18 +113,20 @@ export async function updateAgentApi( name?: string, conversationConfigDict?: Record, platformSettingsDict?: Record, + workflow?: unknown, tags?: string[] ): Promise { const convConfig = conversationConfigDict && isConversationalConfig(conversationConfigDict) ? toCamelCaseKeys(conversationConfigDict) as ConversationalConfig : undefined; const platformSettings = platformSettingsDict && isPlatformSettings(platformSettingsDict) ? toCamelCaseKeys(platformSettingsDict) as AgentPlatformSettingsRequestModel : undefined; - + const response = await client.conversationalAi.agents.update(agentId, { name, conversationConfig: convConfig, platformSettings, + workflow, tags }); - + return response.agentId; }