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
10 changes: 5 additions & 5 deletions src/__tests__/casing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,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];

// Verify workflow edge conditions are converted to camelCase
// Verify workflow edge identifier keys are preserved, but schema fields within are camel-cased
expect(payload.workflow).toBeDefined();
expect(payload.workflow.edges.edgeStartToAgent).toEqual({
expect(payload.workflow.edges.edge_start_to_agent).toEqual({
source: "start_node",
target: "agent_node",
forwardCondition: { type: "unconditional" }
});
expect(payload.workflow.edges.edgeAgentToEnd).toEqual({
expect(payload.workflow.edges.edge_agent_to_end).toEqual({
source: "agent_node",
target: "end_node",
backwardCondition: { type: "result", resultKey: "success" }
Expand Down Expand Up @@ -290,9 +290,9 @@ 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 conditions are converted to camelCase
// Verify workflow edge identifier keys are preserved, but schema fields within are camel-cased
expect(payload.workflow).toBeDefined();
expect(payload.workflow.edges.edgeStartToAgent).toEqual({
expect(payload.workflow.edges.edge_start_to_agent).toEqual({
source: "start_node",
target: "agent_node",
forwardCondition: { type: "llm", description: "When user asks for help" }
Expand Down
127 changes: 127 additions & 0 deletions src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,133 @@ describe("Utils", () => {
});
});

it("should preserve workflow nodes keys in toCamelCaseKeys", () => {
const input = {
workflow: {
nodes: {
"start_node": {
type: "start",
position: { x: 128, y: 64 },
edge_order: []
},
"agent_node": {
type: "agent",
position: { x: 200, y: 100 },
agent_id: "some_agent"
}
},
edges: {
"edge_start_to_agent": {
from: "start_node",
to: "agent_node"
}
},
prevent_subagent_loops: false
}
};

const result = toCamelCaseKeys(input);

expect(result).toEqual({
workflow: {
nodes: {
"start_node": { // preserved - node identifier
type: "start",
position: { x: 128, y: 64 },
edgeOrder: [] // converted - schema field
},
"agent_node": { // preserved - node identifier
type: "agent",
position: { x: 200, y: 100 },
agentId: "some_agent" // converted - schema field
}
},
edges: {
"edge_start_to_agent": { // preserved - edge identifier
from: "start_node",
to: "agent_node"
}
},
preventSubagentLoops: false // converted - schema field
}
});
});

it("should preserve workflow nodes keys in toSnakeCaseKeys", () => {
// Simulating API response (camelCase) being converted to snake_case for storage
const input = {
workflow: {
nodes: {
"start_node": {
type: "start",
position: { x: 128, y: 64 },
edgeOrder: []
}
},
edges: {},
preventSubagentLoops: false
}
};

const result = toSnakeCaseKeys(input);

expect(result).toEqual({
workflow: {
nodes: {
"start_node": { // preserved - node identifier
type: "start",
position: { x: 128, y: 64 },
edge_order: [] // converted - schema field
}
},
edges: {},
prevent_subagent_loops: false // converted - schema field
}
});
});

it("should maintain round-trip conversion symmetry for workflow nodes", () => {
// This is the exact scenario from issue #57:
// pull (API returns camelCase) -> save as snake_case -> push (convert to camelCase)
const originalWorkflow = {
workflow: {
nodes: {
"start_node": {
type: "start",
position: { x: 128, y: 64 },
edge_order: []
}
},
edges: {},
prevent_subagent_loops: false
}
};

// Simulate push: snake_case -> camelCase for API
const afterPush = toCamelCaseKeys(originalWorkflow);

// start_node must remain as-is for the API to recognize the start node
expect(afterPush).toEqual({
workflow: {
nodes: {
"start_node": {
type: "start",
position: { x: 128, y: 64 },
edgeOrder: []
}
},
edges: {},
preventSubagentLoops: false
}
});

// Simulate pull: camelCase -> snake_case for local storage
const afterPull = toSnakeCaseKeys(afterPush);

// Should match original
expect(afterPull).toEqual(originalWorkflow);
});

it("should maintain round-trip conversion symmetry for dynamic_variables", () => {
// Simulate pull → push cycle for tests
const originalTestConfig = {
Expand Down
42 changes: 21 additions & 21 deletions src/__tests__/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,18 @@ 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];

// Workflow should be converted to camelCase for the API
// Workflow node/edge identifier keys should be preserved (not camel-cased)
// Only schema fields within nodes/edges should be converted
expect(payload).toEqual(
expect.objectContaining({
name: "Agent with Workflow",
workflow: expect.objectContaining({
nodes: expect.objectContaining({
start: expect.any(Object),
end: expect.any(Object),
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({
edge1: expect.any(Object), // edge_1 becomes edge1 in camelCase
edge_1: expect.any(Object), // edge_1 preserved as identifier
}),
}),
tags: ["workflow"],
Expand Down Expand Up @@ -175,14 +176,14 @@ describe("Workflow support in agents", () => {
).mock.calls[0];

expect(agentId).toBe("agent_workflow_123");
// Workflow should be converted to camelCase for the API
// Workflow node/edge identifier keys should be preserved (not camel-cased)
expect(payload).toEqual(
expect.objectContaining({
name: "Updated Agent",
workflow: expect.objectContaining({
nodes: expect.objectContaining({
updatedStart: expect.any(Object), // updated_start becomes updatedStart
updatedEnd: expect.any(Object), // updated_end becomes updatedEnd
updated_start: expect.any(Object), // preserved as identifier
updated_end: expect.any(Object), // preserved as identifier
}),
}),
tags: ["updated"],
Expand Down Expand Up @@ -323,20 +324,19 @@ describe("Workflow support in agents", () => {

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

// Workflow should be converted to camelCase for the API
// All snake_case keys become camelCase
expect(payload.workflow.nodes).toHaveProperty("start1"); // start_1 → start1
expect(payload.workflow.nodes).toHaveProperty("agent1"); // agent_1 → agent1
expect(payload.workflow.nodes).toHaveProperty("tool1"); // tool_1 → tool1
expect(payload.workflow.nodes).toHaveProperty("end1"); // end_1 → end1
expect(payload.workflow.edges).toHaveProperty("edgeStartToAgent"); // edge_start_to_agent → edgeStartToAgent
expect(payload.workflow.edges).toHaveProperty("edgeAgentToTool"); // edge_agent_to_tool → edgeAgentToTool
expect(payload.workflow.edges).toHaveProperty("edgeToolToEnd"); // edge_tool_to_end → edgeToolToEnd

// Verify nested properties are also converted
expect(payload.workflow.nodes.start1.config).toHaveProperty("initialMessage"); // initial_message → initialMessage
expect(payload.workflow.nodes.agent1).toHaveProperty("agentId"); // agent_id → agentId
expect(payload.workflow.nodes.tool1).toHaveProperty("toolId"); // tool_id → toolId
// 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
});
});
});
17 changes: 11 additions & 6 deletions src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ function toSnakeCaseKey(key: string): string {
.toLowerCase();
}

// Keys whose children are user-defined identifiers and should not be case-converted.
// Both snake_case and camelCase variants are listed so the check works in either direction.
const PRESERVE_CHILD_KEYS = new Set([
'request_headers', 'requestHeaders',
'dynamic_variables', 'dynamicVariables',
'nodes',
'edges',
]);

export function toCamelCaseKeys<T = unknown>(value: T, skipHeaderConversion: boolean | 'names-only' = false): T {
if (Array.isArray(value)) {
return (value.map((v) => toCamelCaseKeys(v, skipHeaderConversion)) as unknown) as T;
Expand All @@ -133,9 +142,7 @@ export function toCamelCaseKeys<T = unknown>(value: T, skipHeaderConversion: boo
result[k] = toCamelCaseKeys(v, false);
} else {
// Normal conversion
// Preserve keys for request_headers (HTTP header names) and dynamic_variables (user-defined variable names)
const preserveKeys = k === 'request_headers' || k === 'dynamic_variables';
result[toCamelCaseKey(k)] = toCamelCaseKeys(v, preserveKeys ? 'names-only' : false);
result[toCamelCaseKey(k)] = toCamelCaseKeys(v, PRESERVE_CHILD_KEYS.has(k) ? 'names-only' : false);
}
}
return (result as unknown) as T;
Expand All @@ -158,9 +165,7 @@ export function toSnakeCaseKeys<T = unknown>(value: T, skipHeaderConversion: boo
result[k] = toSnakeCaseKeys(v, false);
} else {
// Normal conversion
// Preserve keys for request_headers (HTTP header names) and dynamic_variables (user-defined variable names)
const preserveKeys = k === 'request_headers' || k === 'requestHeaders' || k === 'dynamic_variables' || k === 'dynamicVariables';
result[toSnakeCaseKey(k)] = toSnakeCaseKeys(v, preserveKeys ? 'names-only' : false);
result[toSnakeCaseKey(k)] = toSnakeCaseKeys(v, PRESERVE_CHILD_KEYS.has(k) ? 'names-only' : false);
}
}
return (result as unknown) as T;
Expand Down
Loading