Skip to content

Commit 4bcc76f

Browse files
feat: add agent versioning and branch support (#49, #56) (#59)
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) <noreply@anthropic.com>
1 parent 886fb13 commit 4bcc76f

File tree

9 files changed

+390
-36
lines changed

9 files changed

+390
-36
lines changed

src/__tests__/versioning.test.ts

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { updateAgentApi, getAgentApi } from "../shared/elevenlabs-api";
2+
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
3+
4+
describe("Agent versioning and branch support", () => {
5+
function makeMockClient(opts: {
6+
versionId?: string;
7+
branchId?: string;
8+
mainBranchId?: string;
9+
} = {}) {
10+
const create = jest.fn().mockResolvedValue({ agentId: "agent_ver_123" });
11+
const update = jest.fn().mockResolvedValue({
12+
agentId: "agent_ver_123",
13+
versionId: opts.versionId ?? "ver_abc",
14+
branchId: opts.branchId ?? "branch_main",
15+
});
16+
const get = jest.fn().mockResolvedValue({
17+
agentId: "agent_ver_123",
18+
name: "Test Agent",
19+
versionId: opts.versionId ?? "ver_abc",
20+
branchId: opts.branchId ?? "branch_main",
21+
mainBranchId: opts.mainBranchId ?? "branch_main",
22+
conversationConfig: {
23+
agent: { prompt: { prompt: "Hello", temperature: 0.5 } },
24+
},
25+
platformSettings: {},
26+
tags: [],
27+
});
28+
29+
return {
30+
conversationalAi: {
31+
agents: { create, update, get },
32+
},
33+
} as unknown as ElevenLabsClient;
34+
}
35+
36+
describe("updateAgentApi", () => {
37+
it("should pass versionDescription to the API", async () => {
38+
const client = makeMockClient();
39+
const conversationConfig = {
40+
agent: { prompt: { prompt: "hi", temperature: 0 } },
41+
} as unknown as Record<string, unknown>;
42+
43+
await updateAgentApi(
44+
client,
45+
"agent_ver_123",
46+
"Test Agent",
47+
conversationConfig,
48+
undefined,
49+
undefined,
50+
["tag"],
51+
"release v1.0"
52+
);
53+
54+
expect(client.conversationalAi.agents.update).toHaveBeenCalledTimes(1);
55+
const [agentId, payload] = (
56+
client.conversationalAi.agents.update as jest.Mock
57+
).mock.calls[0];
58+
59+
expect(agentId).toBe("agent_ver_123");
60+
expect(payload).toEqual(
61+
expect.objectContaining({
62+
versionDescription: "release v1.0",
63+
})
64+
);
65+
});
66+
67+
it("should not include versionDescription when not provided", async () => {
68+
const client = makeMockClient();
69+
const conversationConfig = {
70+
agent: { prompt: { prompt: "hi", temperature: 0 } },
71+
} as unknown as Record<string, unknown>;
72+
73+
await updateAgentApi(
74+
client,
75+
"agent_ver_123",
76+
"Test Agent",
77+
conversationConfig,
78+
undefined,
79+
undefined,
80+
[]
81+
);
82+
83+
const [, payload] = (
84+
client.conversationalAi.agents.update as jest.Mock
85+
).mock.calls[0];
86+
87+
expect(payload.versionDescription).toBeUndefined();
88+
});
89+
90+
it("should return versionId and branchId from API response", async () => {
91+
const client = makeMockClient({
92+
versionId: "ver_xyz",
93+
branchId: "branch_feat",
94+
});
95+
const conversationConfig = {
96+
agent: { prompt: { prompt: "hi", temperature: 0 } },
97+
} as unknown as Record<string, unknown>;
98+
99+
const result = await updateAgentApi(
100+
client,
101+
"agent_ver_123",
102+
"Test Agent",
103+
conversationConfig,
104+
undefined,
105+
undefined,
106+
[],
107+
"my version"
108+
);
109+
110+
expect(result).toEqual({
111+
agentId: "agent_ver_123",
112+
versionId: "ver_xyz",
113+
branchId: "branch_feat",
114+
});
115+
});
116+
117+
it("should handle missing versionId/branchId in response", async () => {
118+
const client = makeMockClient();
119+
// Override update to return response without version fields
120+
(client.conversationalAi.agents.update as jest.Mock).mockResolvedValue({
121+
agentId: "agent_ver_123",
122+
});
123+
124+
const conversationConfig = {
125+
agent: { prompt: { prompt: "hi", temperature: 0 } },
126+
} as unknown as Record<string, unknown>;
127+
128+
const result = await updateAgentApi(
129+
client,
130+
"agent_ver_123",
131+
"Test Agent",
132+
conversationConfig
133+
);
134+
135+
expect(result).toEqual({
136+
agentId: "agent_ver_123",
137+
versionId: undefined,
138+
branchId: undefined,
139+
});
140+
});
141+
});
142+
143+
describe("getAgentApi", () => {
144+
it("should return version_id, branch_id, main_branch_id in snake_case", async () => {
145+
const client = makeMockClient({
146+
versionId: "ver_999",
147+
branchId: "branch_dev",
148+
mainBranchId: "branch_main",
149+
});
150+
151+
const response = await getAgentApi(client, "agent_ver_123");
152+
const typed = response as Record<string, unknown>;
153+
154+
// getAgentApi converts to snake_case via toSnakeCaseKeys
155+
expect(typed.version_id).toBe("ver_999");
156+
expect(typed.branch_id).toBe("branch_dev");
157+
expect(typed.main_branch_id).toBe("branch_main");
158+
});
159+
160+
it("should handle agent without version/branch fields", async () => {
161+
const client = makeMockClient();
162+
// Override get to return response without version fields
163+
(client.conversationalAi.agents.get as jest.Mock).mockResolvedValue({
164+
agentId: "agent_ver_123",
165+
name: "Test Agent",
166+
conversationConfig: {},
167+
platformSettings: {},
168+
tags: [],
169+
});
170+
171+
const response = await getAgentApi(client, "agent_ver_123");
172+
const typed = response as Record<string, unknown>;
173+
174+
expect(typed.agent_id).toBe("agent_ver_123");
175+
// Fields should simply be absent
176+
expect(typed.version_id).toBeUndefined();
177+
expect(typed.branch_id).toBeUndefined();
178+
});
179+
});
180+
});
181+
182+
describe("Versioning in push/pull agents.json persistence", () => {
183+
let tempDir: string;
184+
let agentsConfigPath: string;
185+
186+
beforeEach(async () => {
187+
const fs = await import("fs-extra");
188+
const path = await import("path");
189+
const { tmpdir } = await import("os");
190+
tempDir = await fs.mkdtemp(path.join(tmpdir(), "test-versioning-"));
191+
agentsConfigPath = path.join(tempDir, "agents.json");
192+
});
193+
194+
afterEach(async () => {
195+
const fs = await import("fs-extra");
196+
await fs.remove(tempDir);
197+
});
198+
199+
it("should store version_id and branch_id in agents.json structure", async () => {
200+
const { writeConfig, readConfig } = await import("../shared/utils");
201+
202+
// Simulate what push-impl and pull-impl do: write agents.json with version/branch
203+
const agentsConfig = {
204+
agents: [
205+
{
206+
config: "agent_configs/My-Agent.json",
207+
id: "agent_123",
208+
version_id: "ver_abc",
209+
branch_id: "branch_main",
210+
},
211+
{
212+
config: "agent_configs/Another-Agent.json",
213+
id: "agent_456",
214+
// no version/branch - should be fine
215+
},
216+
],
217+
};
218+
219+
await writeConfig(agentsConfigPath, agentsConfig);
220+
221+
const loaded = await readConfig(agentsConfigPath) as {
222+
agents: Array<{
223+
config: string;
224+
id?: string;
225+
version_id?: string;
226+
branch_id?: string;
227+
}>;
228+
};
229+
230+
expect(loaded.agents[0].version_id).toBe("ver_abc");
231+
expect(loaded.agents[0].branch_id).toBe("branch_main");
232+
expect(loaded.agents[1].version_id).toBeUndefined();
233+
expect(loaded.agents[1].branch_id).toBeUndefined();
234+
});
235+
236+
it("should update version_id and branch_id on subsequent pushes", async () => {
237+
const { writeConfig, readConfig } = await import("../shared/utils");
238+
239+
// Initial state
240+
const agentsConfig = {
241+
agents: [
242+
{
243+
config: "agent_configs/My-Agent.json",
244+
id: "agent_123",
245+
version_id: "ver_1",
246+
branch_id: "branch_main",
247+
},
248+
],
249+
};
250+
251+
await writeConfig(agentsConfigPath, agentsConfig);
252+
253+
// Simulate push updating version
254+
const loaded = await readConfig(agentsConfigPath) as {
255+
agents: Array<{
256+
config: string;
257+
id?: string;
258+
version_id?: string;
259+
branch_id?: string;
260+
}>;
261+
};
262+
263+
loaded.agents[0].version_id = "ver_2";
264+
await writeConfig(agentsConfigPath, loaded);
265+
266+
const final = await readConfig(agentsConfigPath) as {
267+
agents: Array<{
268+
config: string;
269+
id?: string;
270+
version_id?: string;
271+
branch_id?: string;
272+
}>;
273+
};
274+
275+
expect(final.agents[0].version_id).toBe("ver_2");
276+
expect(final.agents[0].branch_id).toBe("branch_main");
277+
});
278+
});

src/agents/commands/pull-impl.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const AGENTS_CONFIG_FILE = "agents.json";
1010
interface AgentDefinition {
1111
config: string;
1212
id?: string;
13+
branch_id?: string;
14+
version_id?: string;
1315
}
1416

1517
interface AgentsConfig {
@@ -177,6 +179,8 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath:
177179
platform_settings: Record<string, unknown>;
178180
workflow?: unknown;
179181
tags: string[];
182+
version_id?: string;
183+
branch_id?: string;
180184
};
181185

182186
const conversationConfig = agentDetailsTyped.conversationConfig || agentDetailsTyped.conversation_config || {};
@@ -202,6 +206,11 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath:
202206
const configFilePath = path.resolve(existingEntry.config);
203207
await fs.ensureDir(path.dirname(configFilePath));
204208
await writeConfig(configFilePath, agentConfig);
209+
210+
// Update version/branch info in agents.json entry
211+
if (agentDetailsTyped.version_id) existingEntry.version_id = agentDetailsTyped.version_id;
212+
if (agentDetailsTyped.branch_id) existingEntry.branch_id = agentDetailsTyped.branch_id;
213+
205214
console.log(` ✓ Updated '${agent.name}' (config: ${existingEntry.config})`);
206215
} else {
207216
// Create new entry
@@ -212,7 +221,9 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath:
212221

213222
const newAgent: AgentDefinition = {
214223
config: configPath,
215-
id: agent.id
224+
id: agent.id,
225+
version_id: agentDetailsTyped.version_id,
226+
branch_id: agentDetailsTyped.branch_id
216227
};
217228

218229
agentsConfig.agents.push(newAgent);

src/agents/commands/push-impl.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ const AGENTS_CONFIG_FILE = "agents.json";
99
interface AgentDefinition {
1010
config: string;
1111
id?: string;
12+
branch_id?: string;
13+
version_id?: string;
1214
}
1315

1416
interface AgentsConfig {
1517
agents: AgentDefinition[];
1618
}
1719

18-
export async function pushAgents(dryRun: boolean = false, agentId?: string): Promise<void> {
20+
export async function pushAgents(dryRun: boolean = false, agentId?: string, versionDescription?: string): Promise<void> {
1921
// Load agents configuration
2022
const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE);
2123
if (!(await fs.pathExists(agentsConfigPath))) {
@@ -110,16 +112,21 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string): Pro
110112
changesMade = true;
111113
} else {
112114
// Update existing agent
113-
await updateAgentApi(
115+
const result = await updateAgentApi(
114116
client,
115117
agentId,
116118
agentDisplayName,
117119
conversationConfig,
118120
platformSettings,
119121
workflow,
120-
tags
122+
tags,
123+
versionDescription
121124
);
122125
console.log(`Updated agent ${agentDefName} (ID: ${agentId})`);
126+
127+
// Update version/branch info
128+
if (result.versionId) agentDef.version_id = result.versionId;
129+
if (result.branchId) agentDef.branch_id = result.branchId;
123130
}
124131

125132
changesMade = true;

src/agents/commands/push.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ interface AgentsConfig {
2020
interface PushOptions {
2121
agent?: string;
2222
dryRun: boolean;
23+
versionDescription?: string;
2324
}
2425

2526
export function createPushCommand(): Command {
2627
return new Command('push')
2728
.description('Push agents to ElevenLabs API when configs change')
2829
.option('--agent <agent_id>', 'Specific agent ID to push')
2930
.option('--dry-run', 'Show what would be done without making changes', false)
31+
.option('--version-description <text>', 'Description for the new version (only applies to updates)')
3032
.option('--no-ui', 'Disable interactive UI')
3133
.action(async (options: PushOptions & { ui: boolean }) => {
3234
try {
@@ -61,13 +63,14 @@ export function createPushCommand(): Command {
6163
const { waitUntilExit } = render(
6264
React.createElement(PushView, {
6365
agents: pushAgentsData,
64-
dryRun: options.dryRun
66+
dryRun: options.dryRun,
67+
versionDescription: options.versionDescription
6568
})
6669
);
6770
await waitUntilExit();
6871
} else {
6972
// Use existing non-UI push
70-
await pushAgents(options.dryRun, options.agent);
73+
await pushAgents(options.dryRun, options.agent, options.versionDescription);
7174
}
7275
} catch (error) {
7376
console.error(`Error during push: ${error}`);

0 commit comments

Comments
 (0)