diff --git a/.gitleaksignore b/.gitleaksignore
index f6b00a4644..cda01103ff 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -11,3 +11,10 @@
#
# Review this file periodically to ensure suppressions are still valid.
# ─────────────────────────────────────────────────────────────────
+
+# RestSecretStoreTest rotateKek tests — dummy KEK rotation fixture values (not real secrets)
+06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:404
+06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:413
+06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:423
+06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:427
+06ef33aafb04c721429fef1998eb4727308bae7b:src/test/java/ai/labs/eddi/secrets/rest/RestSecretStoreTest.java:generic-api-key:445
diff --git a/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java b/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java
index de19140ec2..24e4c027d1 100644
--- a/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java
+++ b/src/main/java/ai/labs/eddi/engine/setup/AgentSetupService.java
@@ -496,6 +496,14 @@ private String vaultApiKey(String apiKey, String agentName) {
return apiKey;
}
+ // Already a vault reference — use it directly, don't re-vault (supports legacy
+ // ${eddivault:...})
+ if (SecretReference.isVaultReference(apiKey)
+ && SecretReference.compiledPattern().matcher(apiKey).matches()) {
+ LOGGER.infof("API key for agent '%s' is already a vault reference — using as-is.", agentName);
+ return apiKey;
+ }
+
if (!secretProvider.isAvailable()) {
LOGGER.warn("Secrets Vault is not configured — API key will be stored in plaintext. "
+ "Set EDDI_VAULT_MASTER_KEY to enable encrypted storage.");
diff --git a/src/main/java/ai/labs/eddi/secrets/ISecretProvider.java b/src/main/java/ai/labs/eddi/secrets/ISecretProvider.java
index c2ace0a53a..1ead239649 100644
--- a/src/main/java/ai/labs/eddi/secrets/ISecretProvider.java
+++ b/src/main/java/ai/labs/eddi/secrets/ISecretProvider.java
@@ -110,6 +110,22 @@ public interface ISecretProvider {
*/
int rotateDek(String tenantId) throws SecretProviderException;
+ /**
+ * Reset the vault for a specific tenant. Deletes ALL secrets and the DEK,
+ * allowing the vault to start fresh with the current master key.
+ *
+ * This is a destructive operation — all encrypted secrets for the tenant
+ * will be permanently deleted. This bypasses DEK decryption entirely, making it
+ * safe to call even when the master key has changed.
+ *
+ * @param tenantId
+ * the tenant to reset
+ * @return the number of secrets that were deleted
+ * @throws SecretProviderException
+ * if the reset fails
+ */
+ int resetTenant(String tenantId) throws SecretProviderException;
+
/**
* Check if the secret provider is properly configured and operational.
*
diff --git a/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java b/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java
index d2e09e647a..3db0629c2e 100644
--- a/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java
+++ b/src/main/java/ai/labs/eddi/secrets/impl/VaultSecretProvider.java
@@ -26,6 +26,8 @@
import java.util.*;
import java.util.AbstractMap.SimpleEntry;
+import static ai.labs.eddi.utils.LogSanitizer.sanitize;
+
/**
* Production-grade {@link ISecretProvider} using envelope encryption with
* persistent storage.
@@ -391,6 +393,29 @@ public int rotateKek(String oldMasterKey, String newMasterKey) throws SecretProv
}
}
+ @Override
+ public int resetTenant(String tenantId) throws SecretProviderException {
+ ensureAvailable();
+
+ try {
+ // Delete all secrets first, then the DEK
+ var secrets = persistence.listSecretsByTenant(tenantId);
+ int deletedCount = 0;
+
+ for (var secret : secrets) {
+ if (persistence.deleteSecret(tenantId, secret.getKeyName())) {
+ deletedCount++;
+ }
+ }
+ persistence.deleteDek(tenantId);
+
+ LOGGER.infof("[VAULT] Tenant '%s' reset: %d secret(s) deleted, DEK removed.", sanitize(tenantId), deletedCount);
+ return deletedCount;
+ } catch (PersistenceException e) {
+ throw new SecretProviderException("Failed to reset vault for tenant " + sanitize(tenantId), e);
+ }
+ }
+
// === Private helpers ===
private byte[] getOrCreateDek(String tenantId) throws SecretProviderException {
@@ -398,23 +423,64 @@ private byte[] getOrCreateDek(String tenantId) throws SecretProviderException {
var dekOpt = persistence.findDek(tenantId);
if (dekOpt.isPresent()) {
EncryptedDek encryptedDek = dekOpt.get();
- return EnvelopeCrypto.decryptDek(encryptedDek.getEncryptedDek(), encryptedDek.getIv(), kek);
+ try {
+ return EnvelopeCrypto.decryptDek(encryptedDek.getEncryptedDek(), encryptedDek.getIv(), kek);
+ } catch (EnvelopeCrypto.CryptoException e) {
+ return handleDekDecryptionFailure(tenantId, e);
+ }
}
- // Generate a new DEK for this tenant
- byte[] newDek = EnvelopeCrypto.generateDek();
- EnvelopeCrypto.EncryptionResult encResult = EnvelopeCrypto.encryptDek(newDek, kek);
-
- EncryptedDek dek = new EncryptedDek(UUID.randomUUID().toString(), tenantId, encResult.ciphertext(), encResult.iv(), Instant.now());
-
- persistence.upsertDek(dek);
- LOGGER.infof("Generated new DEK for tenant: %s", tenantId);
- return newDek;
+ return generateAndPersistDek(tenantId);
} catch (PersistenceException e) {
throw new SecretProviderException("Persistence failure while managing DEK for tenant " + tenantId, e);
}
}
+ /**
+ * Handles the case where an existing DEK cannot be decrypted — typically
+ * because EDDI_VAULT_MASTER_KEY changed since the DEK was created.
+ *
+ * Never auto-recovers. Always fails with a clear, actionable error so the user
+ * can choose the appropriate recovery path.
+ */
+ private byte[] handleDekDecryptionFailure(String tenantId, EnvelopeCrypto.CryptoException cause) throws SecretProviderException {
+ int secretCount;
+ try {
+ secretCount = persistence.listSecretsByTenant(tenantId).size();
+ } catch (PersistenceException e) {
+ secretCount = -1; // unknown
+ }
+
+ String secretInfo = secretCount == 0
+ ? "No secrets are stored for this tenant, so no data would be lost by resetting."
+ : secretCount > 0
+ ? secretCount + " secret(s) are stored for this tenant and would be permanently lost if you reset."
+ : "Unable to determine how many secrets are stored for this tenant.";
+
+ throw new SecretProviderException(
+ "Cannot decrypt the Data Encryption Key (DEK) for tenant '" + tenantId + "'. "
+ + "This means the EDDI_VAULT_MASTER_KEY has changed since the DEK was created. "
+ + secretInfo + " "
+ + "Recovery options: "
+ + "(1) Set EDDI_VAULT_MASTER_KEY back to the original value and restart. "
+ + "(2) If you have both old and new keys, use POST /secretstore/secrets/admin/rotate-kek "
+ + "to migrate all encrypted data to the new key. "
+ + "(3) To start fresh (deletes all secrets for this tenant), use "
+ + "POST /secretstore/secrets/" + tenantId + "/reset to clear the vault for this tenant.",
+ cause);
+ }
+
+ private byte[] generateAndPersistDek(String tenantId) {
+ byte[] newDek = EnvelopeCrypto.generateDek();
+ EnvelopeCrypto.EncryptionResult encResult = EnvelopeCrypto.encryptDek(newDek, kek);
+
+ EncryptedDek dek = new EncryptedDek(UUID.randomUUID().toString(), tenantId, encResult.ciphertext(), encResult.iv(), Instant.now());
+
+ persistence.upsertDek(dek);
+ LOGGER.infof("Generated new DEK for tenant: %s", tenantId);
+ return newDek;
+ }
+
private void ensureAvailable() throws SecretProviderException {
if (!available) {
throw new SecretProviderException("Secrets Vault is not available. Set EDDI_VAULT_MASTER_KEY environment variable.");
diff --git a/src/main/java/ai/labs/eddi/secrets/rest/IRestSecretStore.java b/src/main/java/ai/labs/eddi/secrets/rest/IRestSecretStore.java
index 5d70cc3409..ffee3c7753 100644
--- a/src/main/java/ai/labs/eddi/secrets/rest/IRestSecretStore.java
+++ b/src/main/java/ai/labs/eddi/secrets/rest/IRestSecretStore.java
@@ -135,6 +135,28 @@ public interface IRestSecretStore {
+ "EDDI_VAULT_MASTER_KEY environment variable and restart the application.")
Response rotateKek(KekRotationRequest body);
+ /**
+ * Reset the vault for a specific tenant. Deletes ALL secrets and the DEK for
+ * the tenant, allowing the vault to start fresh with the current master key.
+ *
+ * This is a destructive operation — all encrypted secrets for the tenant will
+ * be permanently deleted. Use this when the master key has changed and the old
+ * key is not available.
+ *
+ * @param tenantId
+ * the tenant to reset
+ * @return 200 with details of what was deleted
+ */
+ @POST
+ @Path("/{tenantId}/reset")
+ @Produces(MediaType.APPLICATION_JSON)
+ @RolesAllowed("eddi-admin")
+ @Operation(summary = "Reset vault for a tenant",
+ description = "Deletes ALL secrets and the Data Encryption Key for the tenant. "
+ + "Use this when the master key has changed and recovery is not possible. "
+ + "WARNING: This permanently destroys all encrypted secrets for the tenant.")
+ Response resetTenant(@PathParam("tenantId") String tenantId);
+
/**
* Request body for storing a secret. Includes the plaintext value, an optional
* description, and an optional allowed-agents list.
diff --git a/src/main/java/ai/labs/eddi/secrets/rest/RestSecretStore.java b/src/main/java/ai/labs/eddi/secrets/rest/RestSecretStore.java
index 8e92669442..138464571f 100644
--- a/src/main/java/ai/labs/eddi/secrets/rest/RestSecretStore.java
+++ b/src/main/java/ai/labs/eddi/secrets/rest/RestSecretStore.java
@@ -117,7 +117,7 @@ public Response storeSecret(String tenantId, String keyName, SecretRequest body)
return Response.status(Response.Status.CREATED).entity(responseRef).build();
}
} catch (ISecretProvider.SecretProviderException e) {
- LOGGER.errorf("Failed to store secret: %s/%s — %s", sanitize(tenantId), sanitize(keyName), e.getMessage());
+ LOGGER.error("Failed to store secret: " + sanitize(tenantId) + "/" + sanitize(keyName), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to store secret")).build();
}
}
@@ -142,7 +142,7 @@ public Response deleteSecret(String tenantId, String keyName) {
} catch (ISecretProvider.SecretNotFoundException e) {
return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", "Secret not found")).build();
} catch (ISecretProvider.SecretProviderException e) {
- LOGGER.errorf("Failed to delete secret: %s/%s — %s", sanitize(tenantId), sanitize(keyName), e.getMessage());
+ LOGGER.error("Failed to delete secret: " + sanitize(tenantId) + "/" + sanitize(keyName), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to delete secret")).build();
}
}
@@ -165,7 +165,7 @@ public Response getSecretMetadata(String tenantId, String keyName) {
} catch (ISecretProvider.SecretNotFoundException e) {
return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", "Secret not found")).build();
} catch (ISecretProvider.SecretProviderException e) {
- LOGGER.errorf("Failed to get secret metadata: %s/%s — %s", sanitize(tenantId), sanitize(keyName), e.getMessage());
+ LOGGER.error("Failed to get secret metadata: " + sanitize(tenantId) + "/" + sanitize(keyName), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to get metadata")).build();
}
}
@@ -184,7 +184,7 @@ public Response listSecrets(String tenantId) {
try {
return Response.ok(secretProvider.listKeys(tenantId)).build();
} catch (ISecretProvider.SecretProviderException e) {
- LOGGER.errorf("Failed to list secrets: %s — %s", sanitize(tenantId), e.getMessage());
+ LOGGER.error("Failed to list secrets for tenant: " + sanitize(tenantId), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to list secrets")).build();
}
}
@@ -218,7 +218,7 @@ public Response rotateDek(String tenantId) {
return Response.ok(Map.of("tenantId", tenantId, "secretsReEncrypted", count, "message",
"DEK rotated successfully. " + count + " secrets re-encrypted.")).build();
} catch (ISecretProvider.SecretProviderException e) {
- LOGGER.errorf("Failed to rotate DEK for tenant %s: %s", sanitize(tenantId), e.getMessage());
+ LOGGER.error("Failed to rotate DEK for tenant: " + sanitize(tenantId), e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "DEK rotation failed: " + e.getMessage())).build();
}
}
@@ -249,8 +249,37 @@ public Response rotateKek(KekRotationRequest body) {
return Response.ok(Map.of("deksReEncrypted", count, "message", "KEK rotated successfully. " + count + " DEKs re-encrypted. "
+ "IMPORTANT: Update the EDDI_VAULT_MASTER_KEY environment variable to the new key and restart.")).build();
} catch (ISecretProvider.SecretProviderException e) {
- LOGGER.errorf("Failed to rotate KEK: %s", e.getMessage());
+ LOGGER.error("Failed to rotate KEK", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "KEK rotation failed: " + e.getMessage())).build();
}
}
+
+ @Override
+ public Response resetTenant(String tenantId) {
+ var unavailable = vaultUnavailableResponse();
+ if (unavailable.isPresent())
+ return unavailable.get();
+
+ try {
+ validateId(tenantId, "tenantId");
+ } catch (IllegalArgumentException e) {
+ return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", e.getMessage())).build();
+ }
+
+ try {
+ int deletedSecrets = secretProvider.resetTenant(tenantId);
+ secretResolver.invalidateAll();
+ return Response.ok(Map.of(
+ "tenantId", tenantId,
+ "secretsDeleted", deletedSecrets,
+ "message", "Vault reset for tenant '" + tenantId + "'. "
+ + deletedSecrets + " secret(s) deleted, DEK removed. "
+ + "The next secret store operation will generate a fresh DEK with the current master key."))
+ .build();
+ } catch (ISecretProvider.SecretProviderException e) {
+ LOGGER.error("Failed to reset vault for tenant: " + sanitize(tenantId), e);
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(Map.of("error", "Vault reset failed: " + e.getMessage())).build();
+ }
+ }
}
diff --git a/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java b/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java
index 74de6cc2a6..8595269c47 100644
--- a/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java
+++ b/src/test/java/ai/labs/eddi/configs/agents/AgentSigningServiceTest.java
@@ -178,6 +178,13 @@ public int rotateDek(String tenantId) {
return 0;
}
+ @Override
+ public int resetTenant(String tenantId) {
+ int before = store.size();
+ store.entrySet().removeIf(e -> e.getKey().startsWith(tenantId + ":"));
+ return before - store.size();
+ }
+
@Override
public boolean isAvailable() {
return true;
diff --git a/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java b/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java
new file mode 100644
index 0000000000..65242aca2c
--- /dev/null
+++ b/src/test/java/ai/labs/eddi/engine/setup/AgentSetupServiceBranchCoverageTest.java
@@ -0,0 +1,762 @@
+/*
+ * Copyright EDDI contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package ai.labs.eddi.engine.setup;
+
+import ai.labs.eddi.engine.api.IRestAgentAdministration;
+import ai.labs.eddi.engine.model.Deployment;
+import ai.labs.eddi.engine.runtime.client.factory.IRestInterfaceFactory;
+import ai.labs.eddi.engine.runtime.client.factory.RestInterfaceFactory;
+import ai.labs.eddi.secrets.ISecretProvider;
+import jakarta.ws.rs.core.Response;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.MockitoAnnotations.openMocks;
+
+@DisplayName("AgentSetupService — Branch Coverage")
+class AgentSetupServiceBranchCoverageTest {
+
+ @Mock
+ private IRestInterfaceFactory restInterfaceFactory;
+ @Mock
+ private IRestAgentAdministration agentAdmin;
+ @Mock
+ private ISecretProvider secretProvider;
+
+ private AgentSetupService service;
+
+ @BeforeEach
+ void setUp() {
+ openMocks(this);
+ service = new AgentSetupService(restInterfaceFactory, agentAdmin, secretProvider, "http://localhost:11434");
+ }
+
+ // ─── parseEnvironment ────────────────────────────────────────────────
+
+ @Nested
+ @DisplayName("parseEnvironment")
+ class ParseEnvironment {
+
+ @Test
+ @DisplayName("null returns production")
+ void nullEnv() {
+ assertEquals(Deployment.Environment.production, AgentSetupService.parseEnvironment(null));
+ }
+
+ @Test
+ @DisplayName("blank returns production")
+ void blankEnv() {
+ assertEquals(Deployment.Environment.production, AgentSetupService.parseEnvironment(" "));
+ }
+
+ @Test
+ @DisplayName("'test' returns test")
+ void testEnv() {
+ assertEquals(Deployment.Environment.test, AgentSetupService.parseEnvironment("test"));
+ }
+
+ @Test
+ @DisplayName("'PRODUCTION' returns production")
+ void productionUpperCase() {
+ assertEquals(Deployment.Environment.production, AgentSetupService.parseEnvironment("PRODUCTION"));
+ }
+
+ @Test
+ @DisplayName("invalid value returns production")
+ void invalidEnv() {
+ assertEquals(Deployment.Environment.production, AgentSetupService.parseEnvironment("staging"));
+ }
+ }
+
+ // ─── extractIdFromLocation ───────────────────────────────────────────
+
+ @Nested
+ @DisplayName("extractIdFromLocation")
+ class ExtractId {
+
+ @Test
+ @DisplayName("null returns null")
+ void nullLocation() {
+ assertNull(AgentSetupService.extractIdFromLocation(null));
+ }
+
+ @Test
+ @DisplayName("blank returns null")
+ void blankLocation() {
+ assertNull(AgentSetupService.extractIdFromLocation(" "));
+ }
+
+ @Test
+ @DisplayName("normal location extracts ID")
+ void normalLocation() {
+ assertEquals("abc123", AgentSetupService.extractIdFromLocation("/store/resources/abc123?version=1"));
+ }
+
+ @Test
+ @DisplayName("location without query")
+ void noQuery() {
+ assertEquals("myId", AgentSetupService.extractIdFromLocation("/store/resources/myId"));
+ }
+
+ @Test
+ @DisplayName("trailing slash returns null")
+ void trailingSlash() {
+ assertNull(AgentSetupService.extractIdFromLocation("/store/resources/"));
+ }
+ }
+
+ // ─── extractVersionFromLocation ──────────────────────────────────────
+
+ @Nested
+ @DisplayName("extractVersionFromLocation")
+ class ExtractVersion {
+
+ @Test
+ @DisplayName("null returns 1")
+ void nullLocation() {
+ assertEquals(1, AgentSetupService.extractVersionFromLocation(null));
+ }
+
+ @Test
+ @DisplayName("no version param returns 1")
+ void noVersion() {
+ assertEquals(1, AgentSetupService.extractVersionFromLocation("/store/resources/abc"));
+ }
+
+ @Test
+ @DisplayName("version=3 returns 3")
+ void normalVersion() {
+ assertEquals(3, AgentSetupService.extractVersionFromLocation("/store/resources/abc?version=3"));
+ }
+
+ @Test
+ @DisplayName("version with trailing ¶m returns correctly")
+ void versionWithAmpersand() {
+ assertEquals(5, AgentSetupService.extractVersionFromLocation("/store/resources/abc?version=5&other=foo"));
+ }
+
+ @Test
+ @DisplayName("invalid version number returns 1")
+ void invalidVersion() {
+ assertEquals(1, AgentSetupService.extractVersionFromLocation("/store/resources/abc?version=notanumber"));
+ }
+ }
+
+ // ─── isLocalLlmProvider ──────────────────────────────────────────────
+
+ @Nested
+ @DisplayName("isLocalLlmProvider")
+ class IsLocalLlm {
+
+ @Test
+ @DisplayName("null returns false")
+ void nullProvider() {
+ assertFalse(AgentSetupService.isLocalLlmProvider(null));
+ }
+
+ @Test
+ @DisplayName("blank returns false")
+ void blankProvider() {
+ assertFalse(AgentSetupService.isLocalLlmProvider(" "));
+ }
+
+ @Test
+ @DisplayName("ollama returns true")
+ void ollama() {
+ assertTrue(AgentSetupService.isLocalLlmProvider("ollama"));
+ }
+
+ @Test
+ @DisplayName("jlama returns true")
+ void jlama() {
+ assertTrue(AgentSetupService.isLocalLlmProvider("jlama"));
+ }
+
+ @Test
+ @DisplayName("bedrock returns true")
+ void bedrock() {
+ assertTrue(AgentSetupService.isLocalLlmProvider("bedrock"));
+ }
+
+ @Test
+ @DisplayName("oracle-genai returns true")
+ void oracleGenai() {
+ assertTrue(AgentSetupService.isLocalLlmProvider("oracle-genai"));
+ }
+
+ @Test
+ @DisplayName("OLLAMA (uppercase) returns true")
+ void ollamaUpperCase() {
+ assertTrue(AgentSetupService.isLocalLlmProvider("OLLAMA"));
+ }
+
+ @Test
+ @DisplayName("openai returns false")
+ void openai() {
+ assertFalse(AgentSetupService.isLocalLlmProvider("openai"));
+ }
+
+ @Test
+ @DisplayName("anthropic returns false")
+ void anthropic() {
+ assertFalse(AgentSetupService.isLocalLlmProvider("anthropic"));
+ }
+ }
+
+ // ─── supportsResponseFormat ──────────────────────────────────────────
+
+ @Nested
+ @DisplayName("supportsResponseFormat")
+ class SupportsResponseFormat {
+
+ @Test
+ @DisplayName("openai supports response format")
+ void openai() {
+ assertTrue(AgentSetupService.supportsResponseFormat("openai"));
+ }
+
+ @Test
+ @DisplayName("mistral supports response format")
+ void mistral() {
+ assertTrue(AgentSetupService.supportsResponseFormat("mistral"));
+ }
+
+ @Test
+ @DisplayName("azure-openai supports response format")
+ void azureOpenai() {
+ assertTrue(AgentSetupService.supportsResponseFormat("azure-openai"));
+ }
+
+ @Test
+ @DisplayName("anthropic does not support response format")
+ void anthropic() {
+ assertFalse(AgentSetupService.supportsResponseFormat("anthropic"));
+ }
+
+ @Test
+ @DisplayName("gemini does not support response format")
+ void gemini() {
+ assertFalse(AgentSetupService.supportsResponseFormat("gemini"));
+ }
+
+ @Test
+ @DisplayName("ollama does not support response format")
+ void ollama() {
+ assertFalse(AgentSetupService.supportsResponseFormat("ollama"));
+ }
+ }
+
+ // ─── buildPromptResponseJson ─────────────────────────────────────────
+
+ @Nested
+ @DisplayName("buildPromptResponseJson")
+ class BuildPromptResponseJson {
+
+ @Test
+ @DisplayName("neither quick replies nor sentiment returns null")
+ void neitherEnabled() {
+ assertNull(AgentSetupService.buildPromptResponseJson(false, false));
+ }
+
+ @Test
+ @DisplayName("quick replies only returns JSON with quickReplies")
+ void quickRepliesOnly() {
+ String result = AgentSetupService.buildPromptResponseJson(true, false);
+ assertNotNull(result);
+ assertTrue(result.contains("quickReplies"));
+ assertTrue(result.contains("htmlResponseText"));
+ assertFalse(result.contains("sentiment"));
+ }
+
+ @Test
+ @DisplayName("sentiment only returns JSON with sentiment")
+ void sentimentOnly() {
+ String result = AgentSetupService.buildPromptResponseJson(false, true);
+ assertNotNull(result);
+ assertTrue(result.contains("sentiment"));
+ assertTrue(result.contains("htmlResponseText"));
+ assertFalse(result.contains("quickReplies"));
+ }
+
+ @Test
+ @DisplayName("both enabled returns JSON with both")
+ void bothEnabled() {
+ String result = AgentSetupService.buildPromptResponseJson(true, true);
+ assertNotNull(result);
+ assertTrue(result.contains("quickReplies"));
+ assertTrue(result.contains("sentiment"));
+ assertTrue(result.contains("htmlResponseText"));
+ }
+ }
+
+ // ─── resolveParams ──────────────────────────────────────────────────
+
+ @Nested
+ @DisplayName("resolveParams")
+ class ResolveParams {
+
+ @Test
+ @DisplayName("null provider defaults to anthropic")
+ void nullProvider() {
+ var params = service.resolveParams(null, null, null, null);
+ assertEquals("anthropic", params.providerType());
+ assertEquals("claude-sonnet-4-6", params.modelId());
+ assertTrue(params.shouldDeploy());
+ assertEquals(Deployment.Environment.production, params.env());
+ }
+
+ @Test
+ @DisplayName("blank provider defaults to anthropic")
+ void blankProvider() {
+ var params = service.resolveParams(" ", " ", null, null);
+ assertEquals("anthropic", params.providerType());
+ assertEquals("claude-sonnet-4-6", params.modelId());
+ }
+
+ @Test
+ @DisplayName("explicit provider and model used")
+ void explicitProviderAndModel() {
+ var params = service.resolveParams("openai", "gpt-4", true, "test");
+ assertEquals("openai", params.providerType());
+ assertEquals("gpt-4", params.modelId());
+ assertTrue(params.shouldDeploy());
+ assertEquals(Deployment.Environment.test, params.env());
+ }
+
+ @Test
+ @DisplayName("deploy=false disables deploy")
+ void deployFalse() {
+ var params = service.resolveParams("openai", "gpt-4", false, null);
+ assertFalse(params.shouldDeploy());
+ }
+ }
+
+ // ─── setupAgent validation ──────────────────────────────────────────
+
+ @Nested
+ @DisplayName("setupAgent — validation")
+ class SetupAgentValidation {
+
+ @Test
+ @DisplayName("null agent name throws")
+ void nullAgentName() {
+ var req = new SetupAgentRequest(null, "prompt", "anthropic", "model",
+ "key", null, null, null, null, null, null, null, null, null);
+ assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req));
+ }
+
+ @Test
+ @DisplayName("blank agent name throws")
+ void blankAgentName() {
+ var req = new SetupAgentRequest(" ", "prompt", "anthropic", "model",
+ "key", null, null, null, null, null, null, null, null, null);
+ assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req));
+ }
+
+ @Test
+ @DisplayName("null system prompt throws")
+ void nullSystemPrompt() {
+ var req = new SetupAgentRequest("Agent", null, "anthropic", "model",
+ "key", null, null, null, null, null, null, null, null, null);
+ assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req));
+ }
+
+ @Test
+ @DisplayName("blank system prompt throws")
+ void blankSystemPrompt() {
+ var req = new SetupAgentRequest("Agent", " ", "anthropic", "model",
+ "key", null, null, null, null, null, null, null, null, null);
+ assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req));
+ }
+
+ @Test
+ @DisplayName("cloud provider without API key throws")
+ void cloudProviderNoApiKey() {
+ var req = new SetupAgentRequest("Agent", "prompt", "openai", "gpt-4",
+ null, null, null, null, null, null, null, null, null, null);
+ assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req));
+ }
+
+ @Test
+ @DisplayName("cloud provider with blank API key throws")
+ void cloudProviderBlankApiKey() {
+ var req = new SetupAgentRequest("Agent", "prompt", "anthropic", "model",
+ " ", null, null, null, null, null, null, null, null, null);
+ assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req));
+ }
+
+ @Test
+ @DisplayName("local provider (ollama) without API key does NOT throw for validation")
+ void localProviderNoApiKeyOk() throws Exception {
+ var req = new SetupAgentRequest("Agent", "prompt", "ollama", "llama3",
+ null, null, null, null, null, null, null, null, false, null);
+ // Will fail at REST call, but validation should pass
+ when(restInterfaceFactory.get(any())).thenThrow(new RestInterfaceFactory.RestInterfaceFactoryException("mock", new RuntimeException()));
+ assertThrows(AgentSetupService.AgentSetupException.class, () -> service.setupAgent(req));
+ }
+ }
+
+ // ─── deployAndWait branches ──────────────────────────────────────────
+
+ @Nested
+ @DisplayName("deployAndWait")
+ class DeployAndWait {
+
+ @Test
+ @DisplayName("HTTP 200 with READY status")
+ void http200Ready() {
+ @SuppressWarnings("unchecked")
+ Map body = Map.of("status", "READY");
+ Response response = mock(Response.class);
+ when(response.getStatus()).thenReturn(200);
+ when(response.getEntity()).thenReturn(body);
+ when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response);
+
+ var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1);
+ assertEquals(true, result.get("deployed"));
+ assertEquals("READY", result.get("deploymentStatus"));
+ }
+
+ @Test
+ @DisplayName("HTTP 200 with ERROR status includes error")
+ void http200ErrorStatus() {
+ @SuppressWarnings("unchecked")
+ Map body = Map.of("status", "ERROR", "error", "LLM unreachable");
+ Response response = mock(Response.class);
+ when(response.getStatus()).thenReturn(200);
+ when(response.getEntity()).thenReturn(body);
+ when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response);
+
+ var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1);
+ assertFalse((Boolean) result.get("deployed"));
+ assertEquals("ERROR", result.get("deploymentStatus"));
+ assertNotNull(result.get("deployWarning"));
+ }
+
+ @Test
+ @DisplayName("HTTP 200 with null body → parse error branch")
+ void http200NullBody() {
+ Response response = mock(Response.class);
+ when(response.getStatus()).thenReturn(200);
+ when(response.getEntity()).thenThrow(new ClassCastException("not a map"));
+ when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response);
+
+ var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1);
+ assertEquals(false, result.get("deployed"));
+ assertEquals("UNKNOWN", result.get("deploymentStatus"));
+ }
+
+ @Test
+ @DisplayName("HTTP 202 → in progress")
+ void http202() {
+ Response response = mock(Response.class);
+ when(response.getStatus()).thenReturn(202);
+ when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response);
+
+ var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1);
+ assertEquals(false, result.get("deployed"));
+ assertEquals("IN_PROGRESS", result.get("deploymentStatus"));
+ }
+
+ @Test
+ @DisplayName("HTTP 500 → unexpected status")
+ void http500() {
+ Response response = mock(Response.class);
+ when(response.getStatus()).thenReturn(500);
+ when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean())).thenReturn(response);
+
+ var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1);
+ assertEquals(false, result.get("deployed"));
+ assertNotNull(result.get("deployError"));
+ }
+
+ @Test
+ @DisplayName("deploy throws exception")
+ void deployException() {
+ when(agentAdmin.deployAgent(any(), anyString(), anyInt(), anyBoolean(), anyBoolean()))
+ .thenThrow(new RuntimeException("network error"));
+
+ var result = service.deployAndWait(Deployment.Environment.production, "agent1", 1);
+ assertEquals(false, result.get("deployed"));
+ assertNotNull(result.get("deployError"));
+ }
+ }
+
+ // ─── createLlmConfig branches ────────────────────────────────────────
+
+ @Nested
+ @DisplayName("createLlmConfig — provider branches")
+ class CreateLlmConfig {
+
+ @Test
+ @DisplayName("ollama provider sets baseUrl")
+ void ollamaProvider() {
+ var config = service.createLlmConfig("ollama", "llama3", null, "prompt",
+ false, null, null, null, false, false, null);
+ assertNotNull(config);
+ assertEquals(1, config.tasks().size());
+ var params = config.tasks().get(0).getParameters();
+ assertEquals("llama3", params.get("model"));
+ assertEquals("http://localhost:11434", params.get("baseUrl"));
+ }
+
+ @Test
+ @DisplayName("ollama with custom baseUrl")
+ void ollamaCustomBaseUrl() {
+ var config = service.createLlmConfig("ollama", "llama3", null, "prompt",
+ false, null, "http://custom:1234", null, false, false, null);
+ var params = config.tasks().get(0).getParameters();
+ assertEquals("http://custom:1234", params.get("baseUrl"));
+ }
+
+ @Test
+ @DisplayName("jlama provider sets modelName and authToken")
+ void jlamaProvider() {
+ var config = service.createLlmConfig("jlama", "model-x", "mytoken", "prompt",
+ false, null, null, null, false, false, null);
+ var params = config.tasks().get(0).getParameters();
+ assertEquals("model-x", params.get("modelName"));
+ assertEquals("mytoken", params.get("authToken"));
+ }
+
+ @Test
+ @DisplayName("jlama without API key does not set authToken")
+ void jlamaNoApiKey() {
+ var config = service.createLlmConfig("jlama", "model-x", null, "prompt",
+ false, null, null, null, false, false, null);
+ var params = config.tasks().get(0).getParameters();
+ assertNull(params.get("authToken"));
+ }
+
+ @Test
+ @DisplayName("bedrock provider sets modelId")
+ void bedrockProvider() {
+ var config = service.createLlmConfig("bedrock", "anthropic.claude-3", null, "prompt",
+ false, null, null, null, false, false, null);
+ var params = config.tasks().get(0).getParameters();
+ assertEquals("anthropic.claude-3", params.get("modelId"));
+ }
+
+ @Test
+ @DisplayName("azure-openai sets deploymentName, apiKey, endpoint, responseFormat")
+ void azureOpenai() {
+ String promptJson = "some json format";
+ var config = service.createLlmConfig("azure-openai", "gpt-4", "mykey", "prompt",
+ false, null, "https://myaoi.openai.azure.com", promptJson, false, false, null);
+ var params = config.tasks().get(0).getParameters();
+ assertEquals("gpt-4", params.get("deploymentName"));
+ assertEquals("mykey", params.get("apiKey"));
+ assertEquals("https://myaoi.openai.azure.com", params.get("endpoint"));
+ assertEquals("json", params.get("responseFormat"));
+ }
+
+ @Test
+ @DisplayName("oracle-genai sets modelName")
+ void oracleGenai() {
+ var config = service.createLlmConfig("oracle-genai", "cohere.command", null, "prompt",
+ false, null, null, null, false, false, null);
+ var params = config.tasks().get(0).getParameters();
+ assertEquals("cohere.command", params.get("modelName"));
+ }
+
+ @Test
+ @DisplayName("default provider (anthropic) sets modelName, apiKey, responseFormat if json")
+ void defaultProviderWithJson() {
+ // openai is in 'supportsResponseFormat' so it takes the default branch but with
+ // responseFormat
+ var config = service.createLlmConfig("openai", "gpt-4", "sk-key", "prompt",
+ false, null, "https://custom.api.com", "json schema", false, false, null);
+ var params = config.tasks().get(0).getParameters();
+ assertEquals("gpt-4", params.get("modelName"));
+ assertEquals("sk-key", params.get("apiKey"));
+ assertEquals("https://custom.api.com", params.get("baseUrl"));
+ assertEquals("json", params.get("responseFormat"));
+ }
+
+ @Test
+ @DisplayName("default provider without baseUrl does not set baseUrl")
+ void defaultProviderNoBaseUrl() {
+ var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt",
+ false, null, null, null, false, false, null);
+ var params = config.tasks().get(0).getParameters();
+ assertNull(params.get("baseUrl"));
+ }
+
+ @Test
+ @DisplayName("tooling enabled with whitelist")
+ void toolingWithWhitelist() {
+ var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt",
+ true, "tool1, tool2, tool3", null, null, false, false, null);
+ var task = config.tasks().get(0);
+ assertTrue(task.getEnableBuiltInTools());
+ assertNotNull(task.getBuiltInToolsWhitelist());
+ assertEquals(3, task.getBuiltInToolsWhitelist().size());
+ }
+
+ @Test
+ @DisplayName("tooling enabled without whitelist")
+ void toolingNoWhitelist() {
+ var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt",
+ true, null, null, null, false, false, null);
+ var task = config.tasks().get(0);
+ assertTrue(task.getEnableBuiltInTools());
+ }
+
+ @Test
+ @DisplayName("tool URIs set tools list")
+ void toolUris() {
+ var uris = java.util.List.of("/httpcalls/loc1", "/httpcalls/loc2");
+ var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt",
+ false, null, null, null, false, false, uris);
+ var task = config.tasks().get(0);
+ assertEquals(uris, task.getTools());
+ }
+
+ @Test
+ @DisplayName("promptResponseJson sets postResponse and addToOutput=false")
+ void promptResponseJsonSetsPostResponse() {
+ var config = service.createLlmConfig("anthropic", "claude-3", "key", "prompt",
+ false, null, null, "json format", true, false, null);
+ var task = config.tasks().get(0);
+ assertNotNull(task.getPostResponse());
+ assertEquals("false", task.getParameters().get("addToOutput"));
+ assertEquals("true", task.getParameters().get("convertToObject"));
+ }
+ }
+
+ // ─── buildPostResponse ───────────────────────────────────────────────
+
+ @Nested
+ @DisplayName("buildPostResponse")
+ class BuildPostResponse {
+
+ @Test
+ @DisplayName("without quick replies — no QR instructions")
+ void noQuickReplies() {
+ var postResponse = service.buildPostResponse(false, false);
+ assertNotNull(postResponse.getOutputBuildInstructions());
+ assertNull(postResponse.getQrBuildInstructions());
+ }
+
+ @Test
+ @DisplayName("with quick replies — has QR instructions")
+ void withQuickReplies() {
+ var postResponse = service.buildPostResponse(true, false);
+ assertNotNull(postResponse.getQrBuildInstructions());
+ assertEquals(1, postResponse.getQrBuildInstructions().size());
+ }
+ }
+
+ // ─── createWorkflowConfig branches ───────────────────────────────────
+
+ @Nested
+ @DisplayName("createWorkflowConfig")
+ class CreateWorkflowConfig {
+
+ @Test
+ @DisplayName("all locations provided — full pipeline")
+ void fullPipeline() {
+ var config = service.createWorkflowConfig(
+ "/parser/loc", "/behavior/loc",
+ java.util.List.of("/http1", "/http2"),
+ java.util.List.of("/mcp1"),
+ "/langchain/loc", "/output/loc");
+ // parser + behavior + 2 httpcalls + 1 mcpcalls + langchain + output = 7
+ assertEquals(7, config.getWorkflowSteps().size());
+ }
+
+ @Test
+ @DisplayName("null parser location — skipped")
+ void nullParser() {
+ var config = service.createWorkflowConfig(
+ null, "/behavior/loc",
+ null, null, "/langchain/loc", null);
+ // behavior + langchain = 2
+ assertEquals(2, config.getWorkflowSteps().size());
+ }
+
+ @Test
+ @DisplayName("null output location — skipped")
+ void nullOutput() {
+ var config = service.createWorkflowConfig(
+ "/parser/loc", "/behavior/loc",
+ null, null, "/langchain/loc", null);
+ // parser + behavior + langchain = 3
+ assertEquals(3, config.getWorkflowSteps().size());
+ }
+ }
+
+ // ─── vaultApiKey (private, tested via reflection) ────────────────────
+
+ @Nested
+ @DisplayName("vaultApiKey")
+ class VaultApiKey {
+
+ private String invokeVaultApiKey(String apiKey, String agentName) throws Exception {
+ var method = AgentSetupService.class.getDeclaredMethod("vaultApiKey", String.class, String.class);
+ method.setAccessible(true);
+ return (String) method.invoke(service, apiKey, agentName);
+ }
+
+ @Test
+ @DisplayName("vault reference is passed through as-is (not re-vaulted)")
+ void passthroughVaultReference() throws Exception {
+ String vaultRef = "${vault:anthropic-api-key}";
+
+ String result = invokeVaultApiKey(vaultRef, "MyAgent");
+
+ assertEquals(vaultRef, result, "Already-vaulted reference should be returned unchanged");
+ // Should NOT attempt to store anything
+ verifyNoInteractions(secretProvider);
+ }
+
+ @Test
+ @DisplayName("null apiKey returns null")
+ void nullApiKey() throws Exception {
+ String result = invokeVaultApiKey(null, "MyAgent");
+
+ assertNull(result);
+ verifyNoInteractions(secretProvider);
+ }
+
+ @Test
+ @DisplayName("blank apiKey returns blank")
+ void blankApiKey() throws Exception {
+ String result = invokeVaultApiKey(" ", "MyAgent");
+
+ assertEquals(" ", result);
+ verifyNoInteractions(secretProvider);
+ }
+
+ @Test
+ @DisplayName("legacy ${eddivault:...} reference is passed through as-is")
+ void passthroughLegacyVaultReference() throws Exception {
+ String legacyRef = "${eddivault:old-api-key}";
+
+ String result = invokeVaultApiKey(legacyRef, "LegacyAgent");
+
+ assertEquals(legacyRef, result, "Legacy eddivault reference should be returned unchanged");
+ verifyNoInteractions(secretProvider);
+ }
+
+ @Test
+ @DisplayName("full-form ${vault:tenant/key} reference is passed through as-is")
+ void passthroughFullFormVaultReference() throws Exception {
+ String fullRef = "${vault:my-tenant/my-api-key}";
+
+ String result = invokeVaultApiKey(fullRef, "MultiTenantAgent");
+
+ assertEquals(fullRef, result, "Full-form vault reference should be returned unchanged");
+ verifyNoInteractions(secretProvider);
+ }
+ }
+}
diff --git a/src/test/java/ai/labs/eddi/modules/properties/impl/PropertySetterTaskTest.java b/src/test/java/ai/labs/eddi/modules/properties/impl/PropertySetterTaskTest.java
index 4281a51e09..1d8a18a7d7 100644
--- a/src/test/java/ai/labs/eddi/modules/properties/impl/PropertySetterTaskTest.java
+++ b/src/test/java/ai/labs/eddi/modules/properties/impl/PropertySetterTaskTest.java
@@ -6,6 +6,9 @@
import ai.labs.eddi.configs.properties.model.Property;
import ai.labs.eddi.configs.properties.model.PropertyInstruction;
+import ai.labs.eddi.configs.propertysetter.model.PropertySetterConfiguration;
+import ai.labs.eddi.engine.lifecycle.exceptions.LifecycleException;
+import ai.labs.eddi.engine.lifecycle.exceptions.WorkflowConfigurationException;
import ai.labs.eddi.engine.memory.IConversationMemory;
import ai.labs.eddi.engine.memory.IConversationMemory.*;
import ai.labs.eddi.engine.memory.IData;
@@ -13,6 +16,7 @@
import ai.labs.eddi.engine.memory.IMemoryItemConverter;
import ai.labs.eddi.engine.model.Context;
import ai.labs.eddi.engine.runtime.client.configuration.IResourceClientLibrary;
+import ai.labs.eddi.engine.runtime.service.ServiceException;
import ai.labs.eddi.modules.nlp.expressions.Expressions;
import ai.labs.eddi.modules.nlp.expressions.utilities.IExpressionProvider;
import ai.labs.eddi.modules.properties.IPropertySetter;
@@ -24,7 +28,9 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import java.net.URI;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@@ -406,6 +412,730 @@ void catchAnyInput() throws Exception {
assertDoesNotThrow(() -> task.execute(memory, propertySetter));
verify(conversationProperties).put(eq("user_input"), any(Property.class));
}
+
+ @Test
+ @DisplayName("fromObjectPath with String value — templates and stores as Property")
+ void fromObjectPathStringValue() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("extract"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ var templateDataObjects = new HashMap();
+ templateDataObjects.put("context", Map.of("result", "theValue"));
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("extracted");
+ instruction.setFromObjectPath("context.result");
+ instruction.setScope(Property.Scope.conversation);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("extract"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("extracted"), captor.capture());
+ assertEquals("theValue", captor.getValue().getValueString());
+ }
+
+ @Test
+ @DisplayName("fromObjectPath with Map value — stores as Property with Map value")
+ void fromObjectPathMapValue() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("extract"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ var innerMap = new LinkedHashMap();
+ innerMap.put("a", "1");
+ innerMap.put("b", "2");
+ var templateDataObjects = new HashMap();
+ templateDataObjects.put("context", Map.of("data", innerMap));
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("mapProp");
+ instruction.setFromObjectPath("context.data");
+ instruction.setScope(Property.Scope.conversation);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("extract"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("mapProp"), captor.capture());
+ assertEquals(innerMap, captor.getValue().getValueObject());
+ }
+
+ @Test
+ @DisplayName("fromObjectPath with List value — stores as Property with List value")
+ void fromObjectPathListValue() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("extract"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ var innerList = new ArrayList<>(List.of("x", "y", "z"));
+ var templateDataObjects = new HashMap();
+ templateDataObjects.put("context", Map.of("items", innerList));
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("listProp");
+ instruction.setFromObjectPath("context.items");
+ instruction.setScope(Property.Scope.conversation);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("extract"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("listProp"), captor.capture());
+ assertEquals(innerList, captor.getValue().getValueList());
+ }
+
+ @Test
+ @DisplayName("fromObjectPath with Integer value — stores as Property with Integer value")
+ void fromObjectPathIntegerValue() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("extract"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ var templateDataObjects = new HashMap();
+ templateDataObjects.put("context", Map.of("count", 99));
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("intProp");
+ instruction.setFromObjectPath("context.count");
+ instruction.setScope(Property.Scope.conversation);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("extract"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("intProp"), captor.capture());
+ assertEquals(99, captor.getValue().getValueInt());
+ }
+
+ @Test
+ @DisplayName("fromObjectPath with Float value — stores as Property with Float value")
+ void fromObjectPathFloatValue() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("extract"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ var templateDataObjects = new HashMap();
+ templateDataObjects.put("context", Map.of("rate", 3.14f));
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("floatProp");
+ instruction.setFromObjectPath("context.rate");
+ instruction.setScope(Property.Scope.conversation);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("extract"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("floatProp"), captor.capture());
+ assertEquals(3.14f, captor.getValue().getValueFloat());
+ }
+
+ @Test
+ @DisplayName("fromObjectPath with Boolean value — stores as Property with Boolean value")
+ void fromObjectPathBooleanValue() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("extract"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ var templateDataObjects = new HashMap();
+ templateDataObjects.put("context", Map.of("active", Boolean.TRUE));
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("boolProp");
+ instruction.setFromObjectPath("context.active");
+ instruction.setScope(Property.Scope.conversation);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("extract"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("boolProp"), captor.capture());
+ assertEquals(Boolean.TRUE, captor.getValue().getValueBoolean());
+ }
+
+ @Test
+ @DisplayName("fromObjectPath with toObjectPath set — calls PathNavigator.setValue")
+ void fromObjectPathWithToObjectPath() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("copy"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ // Build a mutable nested map for PathNavigator.setValue to write into
+ var targetMap = new HashMap();
+ var templateDataObjects = new HashMap();
+ templateDataObjects.put("context", Map.of("source", "sourceValue"));
+ templateDataObjects.put("target", targetMap);
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("copied");
+ instruction.setFromObjectPath("context.source");
+ instruction.setToObjectPath("target.dest");
+ instruction.setScope(Property.Scope.conversation);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("copy"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ // When toObjectPath is set, should NOT put into conversationProperties directly
+ verify(conversationProperties, never()).put(eq("copied"), any(Property.class));
+ // Instead, PathNavigator.setValue should have written to the target map
+ assertEquals("sourceValue", targetMap.get("dest"));
+ }
+
+ @Test
+ @DisplayName("scope=secret — auto-vaults plaintext and stores vault reference")
+ void scopeSecretAutoVaults() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("store_secret"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+ when(memory.getAgentId()).thenReturn("agent123");
+
+ // No input data matching the secret, so scrubbing is skipped
+ when(currentStep.getLatestData("input:initial")).thenReturn(null);
+
+ var templateDataObjects = new HashMap();
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("apiKey");
+ instruction.setValueString("my-secret-key-123");
+ instruction.setScope(Property.Scope.secret);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("store_secret"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ // Verify secretProvider.store was called
+ verify(secretProvider).store(any(), eq("my-secret-key-123"), anyString(), anyList());
+
+ // Verify the property is stored with conversation scope (not secret) and vault
+ // ref
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("apiKey"), captor.capture());
+ var storedProperty = captor.getValue();
+ assertEquals(Property.Scope.conversation, storedProperty.getScope());
+ assertTrue(storedProperty.getValueString().startsWith("${vault:"));
+ }
+
+ @Test
+ @DisplayName("scope=secret vault fails — logs error and returns plaintext")
+ void scopeSecretVaultFails() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("store_secret"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+ when(memory.getAgentId()).thenReturn("agent456");
+
+ var templateDataObjects = new HashMap();
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ // Make vault storage fail
+ doThrow(new ISecretProvider.SecretProviderException("Vault unavailable"))
+ .when(secretProvider).store(any(), anyString(), anyString(), anyList());
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("apiKey");
+ instruction.setValueString("plaintext-secret");
+ instruction.setScope(Property.Scope.secret);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("store_secret"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ // Should not throw — graceful degradation
+ assertDoesNotThrow(() -> task.execute(memory, propertySetter));
+
+ // Verify property is stored with plaintext (degraded mode)
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("apiKey"), captor.capture());
+ assertEquals("plaintext-secret", captor.getValue().getValueString());
+ }
+
+ @Test
+ @DisplayName("valueFloat — stores Float property via instruction")
+ void valueFloat() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("set_rate"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ var templateDataObjects = new HashMap();
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("rate");
+ instruction.setValueFloat(9.99f);
+ instruction.setScope(Property.Scope.conversation);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("set_rate"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("rate"), captor.capture());
+ assertEquals(9.99f, captor.getValue().getValueFloat());
+ }
+
+ @Test
+ @DisplayName("valueBoolean — stores Boolean property via instruction")
+ void valueBoolean() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("set_flag"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ var templateDataObjects = new HashMap();
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var instruction = new PropertyInstruction();
+ instruction.setName("enabled");
+ instruction.setValueBoolean(true);
+ instruction.setScope(Property.Scope.conversation);
+ instruction.setOverride(true);
+
+ var setOnActions = new SetOnActions();
+ setOnActions.setActions(List.of("set_flag"));
+ setOnActions.setSetProperties(List.of(instruction));
+
+ var propertySetter = mock(IPropertySetter.class);
+ when(propertySetter.getSetOnActionsList()).thenReturn(List.of(setOnActions));
+ when(propertySetter.extractProperties(any())).thenReturn(new LinkedList<>());
+
+ var previousSteps = mock(IConversationStepStack.class);
+ when(memory.getPreviousSteps()).thenReturn(previousSteps);
+ when(previousSteps.size()).thenReturn(0);
+
+ task.execute(memory, propertySetter);
+
+ var captor = ArgumentCaptor.forClass(Property.class);
+ verify(conversationProperties).put(eq("enabled"), captor.capture());
+ assertEquals(true, captor.getValue().getValueBoolean());
+ }
+
+ @Test
+ @DisplayName("valueList — stores List property via instruction")
+ void valueList() throws Exception {
+ var memory = mock(IConversationMemory.class);
+ var currentStep = mock(IWritableConversationStep.class);
+ when(memory.getCurrentStep()).thenReturn(currentStep);
+
+ when(currentStep.getLatestData("expressions:parsed")).thenReturn(null);
+ when(currentStep.getAllData("context")).thenReturn(null);
+
+ var actionsData = mock(IData.class);
+ when(currentStep.getLatestData("actions")).thenReturn(actionsData);
+ when(actionsData.getResult()).thenReturn(List.of("set_tags"));
+
+ var conversationProperties = mock(IConversationProperties.class);
+ when(memory.getConversationProperties()).thenReturn(conversationProperties);
+
+ var templateDataObjects = new HashMap();
+ when(memoryItemConverter.convert(memory)).thenReturn(templateDataObjects);
+ when(templatingEngine.processTemplate(anyString(), anyMap()))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ var tagList = List.