Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 278 additions & 0 deletions src/__tests__/versioning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { updateAgentApi, getAgentApi } from "../shared/elevenlabs-api";
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";

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",
versionId: opts.versionId ?? "ver_abc",
branchId: opts.branchId ?? "branch_main",
mainBranchId: opts.mainBranchId ?? "branch_main",
conversationConfig: {
agent: { prompt: { prompt: "Hello", temperature: 0.5 } },
},
platformSettings: {},
tags: [],
});

return {
conversationalAi: {
agents: { create, update, get },
},
} as unknown as ElevenLabsClient;
}

describe("updateAgentApi", () => {
it("should pass versionDescription to the API", async () => {
const client = makeMockClient();
const conversationConfig = {
agent: { prompt: { prompt: "hi", temperature: 0 } },
} as unknown as Record<string, unknown>;

await updateAgentApi(
client,
"agent_ver_123",
"Test Agent",
conversationConfig,
undefined,
undefined,
["tag"],
"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.objectContaining({
versionDescription: "release v1.0",
})
);
});

it("should not include versionDescription when not provided", async () => {
const client = makeMockClient();
const conversationConfig = {
agent: { prompt: { prompt: "hi", temperature: 0 } },
} as unknown as Record<string, unknown>;

await updateAgentApi(
client,
"agent_ver_123",
"Test Agent",
conversationConfig,
undefined,
undefined,
[]
);

const [, payload] = (
client.conversationalAi.agents.update as jest.Mock
).mock.calls[0];

expect(payload.versionDescription).toBeUndefined();
});

it("should return versionId and branchId from API response", async () => {
const client = makeMockClient({
versionId: "ver_xyz",
branchId: "branch_feat",
});
const conversationConfig = {
agent: { prompt: { prompt: "hi", temperature: 0 } },
} as unknown as Record<string, unknown>;

const result = await updateAgentApi(
client,
"agent_ver_123",
"Test Agent",
conversationConfig,
undefined,
undefined,
[],
"my version"
);

expect(result).toEqual({
agentId: "agent_ver_123",
versionId: "ver_xyz",
branchId: "branch_feat",
});
});

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",
});

const conversationConfig = {
agent: { prompt: { prompt: "hi", temperature: 0 } },
} as unknown as Record<string, unknown>;

const result = await updateAgentApi(
client,
"agent_ver_123",
"Test Agent",
conversationConfig
);

expect(result).toEqual({
agentId: "agent_ver_123",
versionId: undefined,
branchId: undefined,
});
});
});

describe("getAgentApi", () => {
it("should return version_id, branch_id, main_branch_id in snake_case", async () => {
const client = makeMockClient({
versionId: "ver_999",
branchId: "branch_dev",
mainBranchId: "branch_main",
});

const response = await getAgentApi(client, "agent_ver_123");
const typed = response as Record<string, unknown>;

// getAgentApi converts to snake_case via toSnakeCaseKeys
expect(typed.version_id).toBe("ver_999");
expect(typed.branch_id).toBe("branch_dev");
expect(typed.main_branch_id).toBe("branch_main");
});

it("should handle agent without version/branch fields", async () => {
const client = makeMockClient();
// Override get to return response without version fields
(client.conversationalAi.agents.get as jest.Mock).mockResolvedValue({
agentId: "agent_ver_123",
name: "Test Agent",
conversationConfig: {},
platformSettings: {},
tags: [],
});

const response = await getAgentApi(client, "agent_ver_123");
const typed = response as Record<string, unknown>;

expect(typed.agent_id).toBe("agent_ver_123");
// Fields should simply be absent
expect(typed.version_id).toBeUndefined();
expect(typed.branch_id).toBeUndefined();
});
});
});

describe("Versioning in push/pull agents.json persistence", () => {
let tempDir: string;
let agentsConfigPath: string;

beforeEach(async () => {
const fs = await import("fs-extra");
const path = await import("path");
const { tmpdir } = await import("os");
tempDir = await fs.mkdtemp(path.join(tmpdir(), "test-versioning-"));
agentsConfigPath = path.join(tempDir, "agents.json");
});

afterEach(async () => {
const fs = await import("fs-extra");
await fs.remove(tempDir);
});

it("should store version_id and branch_id in agents.json structure", async () => {
const { writeConfig, readConfig } = await import("../shared/utils");

// Simulate what push-impl and pull-impl do: write agents.json with version/branch
const agentsConfig = {
agents: [
{
config: "agent_configs/My-Agent.json",
id: "agent_123",
version_id: "ver_abc",
branch_id: "branch_main",
},
{
config: "agent_configs/Another-Agent.json",
id: "agent_456",
// no version/branch - should be fine
},
],
};

await writeConfig(agentsConfigPath, agentsConfig);

const loaded = await readConfig(agentsConfigPath) as {
agents: Array<{
config: string;
id?: string;
version_id?: string;
branch_id?: string;
}>;
};

expect(loaded.agents[0].version_id).toBe("ver_abc");
expect(loaded.agents[0].branch_id).toBe("branch_main");
expect(loaded.agents[1].version_id).toBeUndefined();
expect(loaded.agents[1].branch_id).toBeUndefined();
});

it("should update version_id and branch_id on subsequent pushes", async () => {
const { writeConfig, readConfig } = await import("../shared/utils");

// Initial state
const agentsConfig = {
agents: [
{
config: "agent_configs/My-Agent.json",
id: "agent_123",
version_id: "ver_1",
branch_id: "branch_main",
},
],
};

await writeConfig(agentsConfigPath, agentsConfig);

// Simulate push updating version
const loaded = await readConfig(agentsConfigPath) as {
agents: Array<{
config: string;
id?: string;
version_id?: string;
branch_id?: string;
}>;
};

loaded.agents[0].version_id = "ver_2";
await writeConfig(agentsConfigPath, loaded);

const final = await readConfig(agentsConfigPath) as {
agents: Array<{
config: string;
id?: string;
version_id?: string;
branch_id?: string;
}>;
};

expect(final.agents[0].version_id).toBe("ver_2");
expect(final.agents[0].branch_id).toBe("branch_main");
});
});
13 changes: 12 additions & 1 deletion src/agents/commands/pull-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const AGENTS_CONFIG_FILE = "agents.json";
interface AgentDefinition {
config: string;
id?: string;
branch_id?: string;
version_id?: string;
}

interface AgentsConfig {
Expand Down Expand Up @@ -177,6 +179,8 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath:
platform_settings: Record<string, unknown>;
workflow?: unknown;
tags: string[];
version_id?: string;
branch_id?: string;
};

const conversationConfig = agentDetailsTyped.conversationConfig || agentDetailsTyped.conversation_config || {};
Expand All @@ -202,6 +206,11 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath:
const configFilePath = path.resolve(existingEntry.config);
await fs.ensureDir(path.dirname(configFilePath));
await writeConfig(configFilePath, agentConfig);

// Update version/branch info in agents.json entry
if (agentDetailsTyped.version_id) existingEntry.version_id = agentDetailsTyped.version_id;
if (agentDetailsTyped.branch_id) existingEntry.branch_id = agentDetailsTyped.branch_id;

console.log(` ✓ Updated '${agent.name}' (config: ${existingEntry.config})`);
} else {
// Create new entry
Expand All @@ -212,7 +221,9 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath:

const newAgent: AgentDefinition = {
config: configPath,
id: agent.id
id: agent.id,
version_id: agentDetailsTyped.version_id,
branch_id: agentDetailsTyped.branch_id
};

agentsConfig.agents.push(newAgent);
Expand Down
13 changes: 10 additions & 3 deletions src/agents/commands/push-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ const AGENTS_CONFIG_FILE = "agents.json";
interface AgentDefinition {
config: string;
id?: string;
branch_id?: string;
version_id?: string;
}

interface AgentsConfig {
agents: AgentDefinition[];
}

export async function pushAgents(dryRun: boolean = false, agentId?: string): Promise<void> {
export async function pushAgents(dryRun: boolean = false, agentId?: string, versionDescription?: string): Promise<void> {
// Load agents configuration
const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE);
if (!(await fs.pathExists(agentsConfigPath))) {
Expand Down Expand Up @@ -110,16 +112,21 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string): Pro
changesMade = true;
} else {
// Update existing agent
await updateAgentApi(
const result = await updateAgentApi(
client,
agentId,
agentDisplayName,
conversationConfig,
platformSettings,
workflow,
tags
tags,
versionDescription
);
console.log(`Updated agent ${agentDefName} (ID: ${agentId})`);

// Update version/branch info
if (result.versionId) agentDef.version_id = result.versionId;
if (result.branchId) agentDef.branch_id = result.branchId;
}

changesMade = true;
Expand Down
7 changes: 5 additions & 2 deletions src/agents/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ interface AgentsConfig {
interface PushOptions {
agent?: string;
dryRun: boolean;
versionDescription?: string;
}

export function createPushCommand(): Command {
return new Command('push')
.description('Push agents to ElevenLabs API when configs change')
.option('--agent <agent_id>', 'Specific agent ID to push')
.option('--dry-run', 'Show what would be done without making changes', false)
.option('--version-description <text>', 'Description for the new version (only applies to updates)')
.option('--no-ui', 'Disable interactive UI')
.action(async (options: PushOptions & { ui: boolean }) => {
try {
Expand Down Expand Up @@ -61,13 +63,14 @@ export function createPushCommand(): Command {
const { waitUntilExit } = render(
React.createElement(PushView, {
agents: pushAgentsData,
dryRun: options.dryRun
dryRun: options.dryRun,
versionDescription: options.versionDescription
})
);
await waitUntilExit();
} else {
// Use existing non-UI push
await pushAgents(options.dryRun, options.agent);
await pushAgents(options.dryRun, options.agent, options.versionDescription);
}
} catch (error) {
console.error(`Error during push: ${error}`);
Expand Down
Loading
Loading