From 755dc9fc9ee578a60a1f14aafc4c6d2eb5cb2ebd Mon Sep 17 00:00:00 2001 From: Boris Starkov Date: Thu, 26 Feb 2026 14:51:36 +0000 Subject: [PATCH] feat: add agent versioning and branch support (#49, #56) Support agent versioning and branches from the ElevenLabs SDK: - Pass versionDescription on push (--version-description flag) - Store and surface version_id/branch_id in agents.json - Display branch/version info in status output Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/versioning.test.ts | 278 +++++++++++++++++++++++++++++ src/agents/commands/pull-impl.ts | 13 +- src/agents/commands/push-impl.ts | 13 +- src/agents/commands/push.ts | 7 +- src/agents/commands/status-impl.ts | 9 + src/agents/ui/PullView.tsx | 24 ++- src/agents/ui/PushView.tsx | 40 +++-- src/agents/ui/StatusView.tsx | 28 ++- src/shared/elevenlabs-api.ts | 14 +- 9 files changed, 390 insertions(+), 36 deletions(-) create mode 100644 src/__tests__/versioning.test.ts diff --git a/src/__tests__/versioning.test.ts b/src/__tests__/versioning.test.ts new file mode 100644 index 0000000..43c74bc --- /dev/null +++ b/src/__tests__/versioning.test.ts @@ -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; + + 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; + + 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; + + 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; + + 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; + + // 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; + + 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"); + }); +}); diff --git a/src/agents/commands/pull-impl.ts b/src/agents/commands/pull-impl.ts index 83861f8..75e7cae 100644 --- a/src/agents/commands/pull-impl.ts +++ b/src/agents/commands/pull-impl.ts @@ -10,6 +10,8 @@ const AGENTS_CONFIG_FILE = "agents.json"; interface AgentDefinition { config: string; id?: string; + branch_id?: string; + version_id?: string; } interface AgentsConfig { @@ -177,6 +179,8 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: platform_settings: Record; workflow?: unknown; tags: string[]; + version_id?: string; + branch_id?: string; }; const conversationConfig = agentDetailsTyped.conversationConfig || agentDetailsTyped.conversation_config || {}; @@ -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 @@ -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); diff --git a/src/agents/commands/push-impl.ts b/src/agents/commands/push-impl.ts index f43799f..12da370 100644 --- a/src/agents/commands/push-impl.ts +++ b/src/agents/commands/push-impl.ts @@ -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 { +export async function pushAgents(dryRun: boolean = false, agentId?: string, versionDescription?: string): Promise { // Load agents configuration const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); if (!(await fs.pathExists(agentsConfigPath))) { @@ -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; diff --git a/src/agents/commands/push.ts b/src/agents/commands/push.ts index 52bc719..150f610 100644 --- a/src/agents/commands/push.ts +++ b/src/agents/commands/push.ts @@ -20,6 +20,7 @@ interface AgentsConfig { interface PushOptions { agent?: string; dryRun: boolean; + versionDescription?: string; } export function createPushCommand(): Command { @@ -27,6 +28,7 @@ export function createPushCommand(): Command { .description('Push agents to ElevenLabs API when configs change') .option('--agent ', 'Specific agent ID to push') .option('--dry-run', 'Show what would be done without making changes', false) + .option('--version-description ', 'Description for the new version (only applies to updates)') .option('--no-ui', 'Disable interactive UI') .action(async (options: PushOptions & { ui: boolean }) => { try { @@ -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}`); diff --git a/src/agents/commands/status-impl.ts b/src/agents/commands/status-impl.ts index 7850779..812e348 100644 --- a/src/agents/commands/status-impl.ts +++ b/src/agents/commands/status-impl.ts @@ -9,6 +9,8 @@ const AGENTS_CONFIG_FILE = "agents.json"; interface AgentDefinition { config: string; id?: string; + branch_id?: string; + version_id?: string; } interface AgentsConfig { @@ -48,6 +50,13 @@ export async function showStatus(): Promise { const agentId = agentDef.id || 'Not created yet'; console.log(` Agent ID: ${agentId}`); + if (agentDef.branch_id) { + console.log(` Branch ID: ${agentDef.branch_id}`); + } + if (agentDef.version_id) { + console.log(` Version ID: ${agentDef.version_id}`); + } + // Check config file status if (await fs.pathExists(configPath)) { try { diff --git a/src/agents/ui/PullView.tsx b/src/agents/ui/PullView.tsx index 2ec25b8..87aef31 100644 --- a/src/agents/ui/PullView.tsx +++ b/src/agents/ui/PullView.tsx @@ -218,6 +218,8 @@ export const PullView: React.FC = ({ platform_settings?: Record; workflow?: unknown; tags?: string[]; + version_id?: string; + branch_id?: string; }; const conversationConfig = agentDetailsTyped.conversationConfig || agentDetailsTyped.conversation_config || {}; @@ -246,13 +248,17 @@ export const PullView: React.FC = ({ const configFilePath = path.resolve(configPath); await fs.ensureDir(path.dirname(configFilePath)); await writeConfig(configFilePath, agentConfig); - - setAgents(prev => prev.map((a, i) => - i === index ? { - ...a, - status: 'completed' as const, + + // 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; + + setAgents(prev => prev.map((a, i) => + i === index ? { + ...a, + status: 'completed' as const, message: 'Updated successfully', - configPath + configPath } : a )); } else { @@ -262,10 +268,12 @@ export const PullView: React.FC = ({ await fs.ensureDir(path.dirname(configFilePath)); await writeConfig(configFilePath, agentConfig); - // Add to agents config with ID + // Add to agents config with ID and version/branch info agentsConfig.agents.push({ config: configPath, - id: agent.agentId + id: agent.agentId, + version_id: agentDetailsTyped.version_id, + branch_id: agentDetailsTyped.branch_id }); setAgents(prev => prev.map((a, i) => diff --git a/src/agents/ui/PushView.tsx b/src/agents/ui/PushView.tsx index 32a0e07..7db653a 100644 --- a/src/agents/ui/PushView.tsx +++ b/src/agents/ui/PushView.tsx @@ -18,13 +18,15 @@ interface PushAgent { interface PushViewProps { agents: PushAgent[]; dryRun?: boolean; + versionDescription?: string; onComplete?: () => void; agentsConfigPath?: string; } -export const PushView: React.FC = ({ - agents, +export const PushView: React.FC = ({ + agents, dryRun = false, + versionDescription, onComplete, agentsConfigPath = 'agents.json' }) => { @@ -122,11 +124,11 @@ export const PushView: React.FC = ({ await writeConfig(path.resolve(agentsConfigPath), agentsConfig); } - setPushedAgents(prev => - prev.map((a, i) => i === currentAgentIndex - ? { - ...a, - status: 'completed', + setPushedAgents(prev => + prev.map((a, i) => i === currentAgentIndex + ? { + ...a, + status: 'completed', message: 'Successfully pushed', agentId: newAgentId } @@ -135,21 +137,31 @@ export const PushView: React.FC = ({ ); } else { // Update existing agent - await updateAgentApi( + const result = await updateAgentApi( client, agentId, agentDisplayName, conversationConfig, platformSettings, workflow, - tags + tags, + versionDescription ); - setPushedAgents(prev => - prev.map((a, i) => i === currentAgentIndex - ? { - ...a, - status: 'completed', + // Update version/branch info in agents.json + const agentsConfig = await readConfig(path.resolve(agentsConfigPath)); + const agentDef = agentsConfig.agents.find((a: any) => a.config === agent.configPath); + if (agentDef) { + if (result.versionId) agentDef.version_id = result.versionId; + if (result.branchId) agentDef.branch_id = result.branchId; + await writeConfig(path.resolve(agentsConfigPath), agentsConfig); + } + + setPushedAgents(prev => + prev.map((a, i) => i === currentAgentIndex + ? { + ...a, + status: 'completed', message: 'Successfully pushed', agentId } diff --git a/src/agents/ui/StatusView.tsx b/src/agents/ui/StatusView.tsx index 78a3eec..7bb7b6b 100644 --- a/src/agents/ui/StatusView.tsx +++ b/src/agents/ui/StatusView.tsx @@ -12,6 +12,8 @@ interface AgentStatus { configPath: string; configExists: boolean; agentId?: string; + branchId?: string; + versionId?: string; status: 'created' | 'not-pushed' | 'missing'; } @@ -75,6 +77,8 @@ export const StatusView: React.FC = ({ configPath, configExists, agentId, + branchId: (agentDef as any).branch_id, + versionId: (agentDef as any).version_id, status }); } @@ -152,12 +156,18 @@ export const StatusView: React.FC = ({ {/* Table Header */} - + NAME - + STATUS + + BRANCH + + + VERSION + AGENT ID @@ -193,12 +203,22 @@ export const StatusView: React.FC = ({ return ( - + {agent.name} - + {statusText} + + + {agent.branchId ? agent.branchId.slice(0, 20) : '-'} + + + + + {agent.versionId ? agent.versionId.slice(0, 20) : '-'} + + {agent.agentId || '-'} diff --git a/src/shared/elevenlabs-api.ts b/src/shared/elevenlabs-api.ts index 1ace94a..6ead6dc 100644 --- a/src/shared/elevenlabs-api.ts +++ b/src/shared/elevenlabs-api.ts @@ -150,8 +150,9 @@ export async function updateAgentApi( conversationConfigDict?: Record, platformSettingsDict?: Record, workflow?: unknown, - tags?: string[] -): Promise { + tags?: string[], + versionDescription?: string +): Promise<{ agentId: string; versionId?: string; branchId?: string }> { // Clean config to remove deprecated 'tools' if 'tool_ids' exists const cleanedConfig = conversationConfigDict ? cleanConversationConfigForApi(conversationConfigDict) : undefined; @@ -165,10 +166,15 @@ export async function updateAgentApi( conversationConfig: convConfig, platformSettings, workflow: workflowConfig, - tags + tags, + versionDescription }); - return response.agentId; + return { + agentId: response.agentId, + versionId: response.versionId, + branchId: response.branchId + }; } /**