From 0c7390d7ba05afd546bba7bdc6ac5ffe5eb1d30f Mon Sep 17 00:00:00 2001 From: Boris Starkov Date: Thu, 26 Feb 2026 15:13:26 +0000 Subject: [PATCH] fix: preserve workflow node/edge identifier keys from case conversion (#57) Workflow nodes and edges keys (e.g. start_node, edge_start_to_agent) are user-defined identifiers, not schema fields. toCamelCaseKeys was converting them (start_node -> startNode), causing the API to reject pushes with "Workflow must contain a start node". Extract preserved-key checks into a shared PRESERVE_CHILD_KEYS set and add nodes/edges alongside the existing request_headers and dynamic_variables entries. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/casing.test.ts | 10 +-- src/__tests__/utils.test.ts | 127 +++++++++++++++++++++++++++++++++ src/__tests__/workflow.test.ts | 42 +++++------ src/shared/utils.ts | 17 +++-- 4 files changed, 164 insertions(+), 32 deletions(-) diff --git a/src/__tests__/casing.test.ts b/src/__tests__/casing.test.ts index 6f3b473..9b6fc37 100644 --- a/src/__tests__/casing.test.ts +++ b/src/__tests__/casing.test.ts @@ -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" } @@ -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" } diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 571778f..420ede4 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -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 = { diff --git a/src/__tests__/workflow.test.ts b/src/__tests__/workflow.test.ts index 0c2c9ef..ee94d95 100644 --- a/src/__tests__/workflow.test.ts +++ b/src/__tests__/workflow.test.ts @@ -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"], @@ -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"], @@ -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 }); }); }); diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 0f8e52c..6bf2744 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -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(value: T, skipHeaderConversion: boolean | 'names-only' = false): T { if (Array.isArray(value)) { return (value.map((v) => toCamelCaseKeys(v, skipHeaderConversion)) as unknown) as T; @@ -133,9 +142,7 @@ export function toCamelCaseKeys(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; @@ -158,9 +165,7 @@ export function toSnakeCaseKeys(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;