From 35704a96954dd16159189f60a99cd76bb2a117db Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 03:20:19 +0200 Subject: [PATCH 01/35] feat(channels): add ChannelIntegrationConfiguration resource + store + REST API Standalone config resource replacing ChannelConnector on agents. - ChannelIntegrationConfiguration with multi-target support - ChannelTarget with trigger keywords, AGENT/GROUP type, observe mode schema - ObserveConfig with dollar-based cost ceilings (schema-ready, impl deferred) - IChannelIntegrationStore + MongoChannelIntegrationStore (AbstractResourceStore) - IRestChannelIntegrationStore + RestChannelIntegrationStore (admin-only) - Validation: trigger uniqueness, channel type, default target, target completeness - Resource URI: eddi://ai.labs.channel/channelstore/channels/ --- .../channels/IChannelIntegrationStore.java | 13 + .../IRestChannelIntegrationStore.java | 82 ++++++ .../ChannelIntegrationConfiguration.java | 105 ++++++++ .../configs/channels/model/ChannelTarget.java | 110 +++++++++ .../configs/channels/model/ObserveConfig.java | 92 +++++++ .../mongo/ChannelIntegrationStore.java | 31 +++ .../rest/RestChannelIntegrationStore.java | 233 ++++++++++++++++++ 7 files changed, 666 insertions(+) create mode 100644 src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java create mode 100644 src/main/java/ai/labs/eddi/configs/channels/IRestChannelIntegrationStore.java create mode 100644 src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java create mode 100644 src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java create mode 100644 src/main/java/ai/labs/eddi/configs/channels/model/ObserveConfig.java create mode 100644 src/main/java/ai/labs/eddi/configs/channels/mongo/ChannelIntegrationStore.java create mode 100644 src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java diff --git a/src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java new file mode 100644 index 000000000..6a15a443a --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java @@ -0,0 +1,13 @@ +package ai.labs.eddi.configs.channels; + +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.datastore.IResourceStore; + +/** + * Store interface for channel integration configurations. Uses the DB-agnostic + * {@code AbstractResourceStore} via {@code IResourceStorageFactory}. + * + * @since 6.1.0 + */ +public interface IChannelIntegrationStore extends IResourceStore { +} diff --git a/src/main/java/ai/labs/eddi/configs/channels/IRestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/IRestChannelIntegrationStore.java new file mode 100644 index 000000000..9798a6f66 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/IRestChannelIntegrationStore.java @@ -0,0 +1,82 @@ +package ai.labs.eddi.configs.channels; + +import ai.labs.eddi.configs.IRestVersionInfo; +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.configs.descriptors.model.DocumentDescriptor; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import java.util.List; + +/** + * JAX-RS interface for channel integration configuration CRUD. + *

+ * Admin-only — channel configurations expose target topology and vault + * references. Same security posture as {@code IRestAgentStore}. + * + * @since 6.1.0 + */ +@Path("/channelstore/channels") +@Tag(name = "Channel Integrations") +@RolesAllowed({"eddi-admin", "eddi-editor"}) +public interface IRestChannelIntegrationStore extends IRestVersionInfo { + String resourceBaseType = "eddi://ai.labs.channel"; + String resourceURI = resourceBaseType + "/channelstore/channels/"; + + @GET + @Path("/descriptors") + @Produces(MediaType.APPLICATION_JSON) + @Operation(description = "Read list of channel integration descriptors.") + List readChannelDescriptors( + @QueryParam("filter") + @DefaultValue("") String filter, + @QueryParam("index") + @DefaultValue("0") Integer index, + @QueryParam("limit") + @DefaultValue("20") Integer limit); + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(description = "Read channel integration configuration.") + ChannelIntegrationConfiguration readChannel( + @PathParam("id") String id, + @Parameter(name = "version", required = true, example = "1") + @QueryParam("version") Integer version); + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(description = "Update channel integration configuration.") + Response updateChannel( + @PathParam("id") String id, + @Parameter(name = "version", required = true, example = "1") + @QueryParam("version") Integer version, + ChannelIntegrationConfiguration channelConfiguration); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Operation(description = "Create channel integration configuration.") + Response createChannel(ChannelIntegrationConfiguration channelConfiguration); + + @POST + @Path("/{id}") + @Operation(description = "Duplicate this channel integration configuration.") + Response duplicateChannel(@PathParam("id") String id, + @QueryParam("version") Integer version); + + @DELETE + @Path("/{id}") + @Operation(description = "Delete channel integration configuration.") + Response deleteChannel( + @PathParam("id") String id, + @Parameter(name = "version", required = true, example = "1") + @QueryParam("version") Integer version, + @QueryParam("permanent") + @DefaultValue("false") Boolean permanent); +} diff --git a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java new file mode 100644 index 000000000..0a4c4a3d3 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java @@ -0,0 +1,105 @@ +package ai.labs.eddi.configs.channels.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Standalone configuration for a channel integration. Decouples channel routing + * and credentials from {@code AgentConfiguration}, supporting multi-target + * channels and cross-platform extensibility (Slack, Teams, Discord). + *

+ * One {@code ChannelIntegrationConfiguration} represents one platform channel + * (e.g., a single Slack channel) with one or more {@link ChannelTarget}s that + * map trigger keywords to agents or groups. + * + * @since 6.1.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ChannelIntegrationConfiguration { + + private String name; + private String channelType; + private Map platformConfig; + private List targets; + private String defaultTargetName; + + public ChannelIntegrationConfiguration() { + this.platformConfig = new HashMap<>(); + this.targets = new ArrayList<>(); + } + + /** + * Human-readable name for this integration (e.g., "Engineering AI Hub"). + */ + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + /** + * Platform type. Validated server-side against registered adapters. Currently + * supported: {@code "slack"}. Future: {@code "teams"}, {@code "discord"}. + *

+ * Deliberately a String (not an enum) so downstream forks can register custom + * adapters without recompiling core. + */ + public String getChannelType() { + return channelType; + } + + public void setChannelType(String channelType) { + this.channelType = channelType; + } + + /** + * Platform-specific credentials and identifiers. Keys depend on + * {@link #channelType}: + *

+ * Secret values should use vault references: {@code ${eddivault:key-name}}. + */ + public Map getPlatformConfig() { + return platformConfig; + } + + public void setPlatformConfig(Map platformConfig) { + this.platformConfig = platformConfig; + } + + /** + * Available targets in this channel. Each target maps trigger keywords to an + * agent or group. At least one target is required. + */ + public List getTargets() { + return targets; + } + + public void setTargets(List targets) { + this.targets = targets; + } + + /** + * Name of the target to use when no trigger keyword matches. Must reference an + * existing target in {@link #targets}. Required. + */ + public String getDefaultTargetName() { + return defaultTargetName; + } + + public void setDefaultTargetName(String defaultTargetName) { + this.defaultTargetName = defaultTargetName; + } +} diff --git a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java new file mode 100644 index 000000000..0ebd5f29b --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java @@ -0,0 +1,110 @@ +package ai.labs.eddi.configs.channels.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.ArrayList; +import java.util.List; + +/** + * A single addressable target within a channel integration. Maps trigger + * keywords to an agent or group. + *

+ * Users address a target by typing {@code triggerKeyword: message} in the + * channel. If no trigger matches, the channel's default target handles the + * message. + * + * @since 6.1.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ChannelTarget { + + /** + * Target type — either a single agent or a multi-agent group discussion. + */ + public enum TargetType { + AGENT, GROUP + } + + private String name; + private List triggers; + private TargetType type; + private String targetId; + private boolean observeMode; + private ObserveConfig observeConfig; + + public ChannelTarget() { + this.triggers = new ArrayList<>(); + this.type = TargetType.AGENT; + } + + /** + * Display name shown in help messages (e.g., "Architect", "Review Panel"). + */ + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + /** + * Exact trigger keywords (case-insensitive). The user types one of these + * followed by a colon to address this target: {@code architect: question}. + */ + public List getTriggers() { + return triggers; + } + + public void setTriggers(List triggers) { + this.triggers = triggers; + } + + /** + * Whether this target addresses a single agent or a group discussion. + */ + public TargetType getType() { + return type; + } + + public void setType(TargetType type) { + this.type = type; + } + + /** + * The agent ID or group ID this target routes to, depending on {@link #type}. + */ + public String getTargetId() { + return targetId; + } + + public void setTargetId(String targetId) { + this.targetId = targetId; + } + + /** + * If {@code true}, this target passively observes all channel messages and + * selectively responds based on {@link #observeConfig} filters. + *

+ * Note: Observe mode is schema-ready but implementation is deferred. + */ + public boolean isObserveMode() { + return observeMode; + } + + public void setObserveMode(boolean observeMode) { + this.observeMode = observeMode; + } + + /** + * Configuration for passive observation — only meaningful when + * {@link #observeMode} is {@code true}. Set to {@code null} otherwise. + */ + public ObserveConfig getObserveConfig() { + return observeConfig; + } + + public void setObserveConfig(ObserveConfig observeConfig) { + this.observeConfig = observeConfig; + } +} diff --git a/src/main/java/ai/labs/eddi/configs/channels/model/ObserveConfig.java b/src/main/java/ai/labs/eddi/configs/channels/model/ObserveConfig.java new file mode 100644 index 000000000..c291f2c8c --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ObserveConfig.java @@ -0,0 +1,92 @@ +package ai.labs.eddi.configs.channels.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.ArrayList; +import java.util.List; + +/** + * Configuration for passive channel observation. Controls when an observer + * target decides to respond to channel messages it silently monitors. + *

+ * Cost control follows EDDI convention (§4.7 of AGENTS.md): dollar-based + * ceiling ({@link #maxCostPerDay}) is primary; call count + * ({@link #maxDailyResponses}) is a secondary hard cap. + *

+ * Note: Schema-ready; implementation deferred to a future PR. + * + * @since 6.1.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ObserveConfig { + + private List triggerKeywords; + private List triggerMimeTypes; + private int cooldownSeconds = 60; + private int maxDailyResponses = 50; + private double maxCostPerDay = 5.0; + + public ObserveConfig() { + this.triggerKeywords = new ArrayList<>(); + this.triggerMimeTypes = new ArrayList<>(); + } + + /** + * Only invoke the agent if the message contains one of these keywords + * (case-insensitive substring match). Empty list = match all messages. + */ + public List getTriggerKeywords() { + return triggerKeywords; + } + + public void setTriggerKeywords(List triggerKeywords) { + this.triggerKeywords = triggerKeywords; + } + + /** + * Trigger on file attachments with these MIME types (e.g., + * {@code "application/pdf"}). Empty list = don't trigger on file types. + */ + public List getTriggerMimeTypes() { + return triggerMimeTypes; + } + + public void setTriggerMimeTypes(List triggerMimeTypes) { + this.triggerMimeTypes = triggerMimeTypes; + } + + /** + * Minimum seconds between responses in observe mode (prevents spam). Default: + * 60. + */ + public int getCooldownSeconds() { + return cooldownSeconds; + } + + public void setCooldownSeconds(int cooldownSeconds) { + this.cooldownSeconds = cooldownSeconds; + } + + /** + * Hard cap on number of responses per day. Secondary control — use + * {@link #maxCostPerDay} as the primary ceiling. Default: 50. + */ + public int getMaxDailyResponses() { + return maxDailyResponses; + } + + public void setMaxDailyResponses(int maxDailyResponses) { + this.maxDailyResponses = maxDailyResponses; + } + + /** + * Dollar-based cost ceiling per day. Primary cost control. Default: $5.00. + */ + public double getMaxCostPerDay() { + return maxCostPerDay; + } + + public void setMaxCostPerDay(double maxCostPerDay) { + this.maxCostPerDay = maxCostPerDay; + } +} diff --git a/src/main/java/ai/labs/eddi/configs/channels/mongo/ChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/mongo/ChannelIntegrationStore.java new file mode 100644 index 000000000..f90d40447 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/mongo/ChannelIntegrationStore.java @@ -0,0 +1,31 @@ +package ai.labs.eddi.configs.channels.mongo; + +import ai.labs.eddi.configs.channels.IChannelIntegrationStore; +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.datastore.AbstractResourceStore; +import ai.labs.eddi.datastore.IResourceStorageFactory; +import ai.labs.eddi.datastore.serialization.IDocumentBuilder; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * DB-agnostic store for channel integration configurations. Extends + * {@link AbstractResourceStore} which delegates to either MongoDB or PostgreSQL + * via {@link IResourceStorageFactory}. + * + * @since 6.1.0 + */ +@ApplicationScoped +public class ChannelIntegrationStore + extends + AbstractResourceStore + implements + IChannelIntegrationStore { + + @Inject + public ChannelIntegrationStore(IResourceStorageFactory storageFactory, + IDocumentBuilder documentBuilder) { + super(storageFactory, "channels", documentBuilder, + ChannelIntegrationConfiguration.class); + } +} diff --git a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java new file mode 100644 index 000000000..b757375c7 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java @@ -0,0 +1,233 @@ +package ai.labs.eddi.configs.channels.rest; + +import ai.labs.eddi.configs.channels.IChannelIntegrationStore; +import ai.labs.eddi.configs.channels.IRestChannelIntegrationStore; +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.configs.channels.model.ChannelTarget; +import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; +import ai.labs.eddi.configs.descriptors.model.DocumentDescriptor; +import ai.labs.eddi.configs.rest.RestVersionInfo; +import ai.labs.eddi.datastore.IResourceStore; +import ai.labs.eddi.utils.RestUtilities; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; + +import java.net.URI; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * REST implementation for channel integration configuration CRUD. Includes + * validation for trigger uniqueness, default target, and channel type. + * + * @since 6.1.0 + */ +@ApplicationScoped +public class RestChannelIntegrationStore implements IRestChannelIntegrationStore { + private static final Logger LOG = Logger.getLogger(RestChannelIntegrationStore.class); + + /** + * Currently registered channel type adapters. Future: make this discoverable + * via CDI so forks can register custom adapters. + */ + private static final Set REGISTERED_CHANNEL_TYPES = Set.of("slack"); + + private final IChannelIntegrationStore channelStore; + private final IDocumentDescriptorStore documentDescriptorStore; + private final RestVersionInfo restVersionInfo; + + @Inject + public RestChannelIntegrationStore(IChannelIntegrationStore channelStore, + IDocumentDescriptorStore documentDescriptorStore) { + restVersionInfo = new RestVersionInfo<>(resourceURI, channelStore, documentDescriptorStore); + this.channelStore = channelStore; + this.documentDescriptorStore = documentDescriptorStore; + } + + @Override + public List readChannelDescriptors(String filter, Integer index, Integer limit) { + return restVersionInfo.readDescriptors("ai.labs.channel", filter, index, limit); + } + + @Override + public ChannelIntegrationConfiguration readChannel(String id, Integer version) { + return restVersionInfo.read(id, version); + } + + @Override + public Response updateChannel(String id, Integer version, + ChannelIntegrationConfiguration channelConfiguration) { + validateConfiguration(channelConfiguration); + Response response = restVersionInfo.update(id, version, channelConfiguration); + syncDescriptor(id, channelConfiguration); + return response; + } + + @Override + public Response createChannel(ChannelIntegrationConfiguration channelConfiguration) { + validateConfiguration(channelConfiguration); + Response response = restVersionInfo.create(channelConfiguration); + URI location = response.getLocation(); + if (location != null) { + try { + var resourceId = RestUtilities.extractResourceId(location); + syncDescriptor(resourceId.getId(), channelConfiguration); + } catch (Exception e) { + LOG.warn("Failed to sync channel descriptor on create", e); + } + } + return response; + } + + @Override + public Response duplicateChannel(String id, Integer version) { + restVersionInfo.validateParameters(id, version); + ChannelIntegrationConfiguration config = restVersionInfo.read(id, version); + Response response = restVersionInfo.create(config); + URI location = response.getLocation(); + if (location != null) { + try { + var resourceId = RestUtilities.extractResourceId(location); + syncDescriptor(resourceId.getId(), config); + } catch (Exception e) { + LOG.warn("Failed to sync channel descriptor on duplicate", e); + } + } + return response; + } + + @Override + public Response deleteChannel(String id, Integer version, Boolean permanent) { + return restVersionInfo.delete(id, version, permanent); + } + + @Override + public String getResourceURI() { + return restVersionInfo.getResourceURI(); + } + + @Override + public IResourceStore.IResourceId getCurrentResourceId(String id) + throws IResourceStore.ResourceNotFoundException { + return channelStore.getCurrentResourceId(id); + } + + // ─── Validation ──────────────────────────────────────────────────────────── + + private void validateConfiguration(ChannelIntegrationConfiguration config) { + if (config.getName() == null || config.getName().isBlank()) { + throw new BadRequestException("Channel integration name is required."); + } + + // Channel type must be a registered adapter + String channelType = config.getChannelType(); + if (channelType == null || channelType.isBlank()) { + throw new BadRequestException("Channel type is required."); + } + if (!REGISTERED_CHANNEL_TYPES.contains(channelType.toLowerCase())) { + throw new BadRequestException( + "Unknown channel type: '" + channelType + "'. Registered types: " + + REGISTERED_CHANNEL_TYPES); + } + + // At least one target + List targets = config.getTargets(); + if (targets == null || targets.isEmpty()) { + throw new BadRequestException("At least one target is required."); + } + + // Default target must reference an existing target + String defaultName = config.getDefaultTargetName(); + if (defaultName == null || defaultName.isBlank()) { + throw new BadRequestException("Default target name is required."); + } + boolean defaultFound = targets.stream() + .anyMatch(t -> t.getName() != null + && t.getName().equalsIgnoreCase(defaultName)); + if (!defaultFound) { + throw new BadRequestException( + "Default target '" + defaultName + + "' does not match any target name."); + } + + // No duplicate trigger keywords across targets + Set allTriggers = new HashSet<>(); + for (ChannelTarget target : targets) { + if (target.getName() == null || target.getName().isBlank()) { + throw new BadRequestException("Every target must have a name."); + } + if (target.getTargetId() == null || target.getTargetId().isBlank()) { + throw new BadRequestException( + "Target '" + target.getName() + "' must have a targetId."); + } + if (target.getTriggers() != null) { + for (String trigger : target.getTriggers()) { + String normalized = trigger.toLowerCase().trim(); + if (!allTriggers.add(normalized)) { + throw new BadRequestException( + "Duplicate trigger keyword: '" + trigger + + "'. Each trigger must be unique across all targets."); + } + } + } + } + } + + // ─── Descriptor sync ─────────────────────────────────────────────────────── + + /** + * Sync the channel config's name onto the DocumentDescriptor so that the + * descriptors endpoint returns meaningful display information. + */ + private void syncDescriptor(String resourceId, + ChannelIntegrationConfiguration config) { + try { + var currentResourceId = channelStore.getCurrentResourceId(resourceId); + var descriptor = documentDescriptorStore.readDescriptor( + resourceId, currentResourceId.getVersion()); + boolean changed = false; + + if (config.getName() != null + && !config.getName().equals(descriptor.getName())) { + descriptor.setName(config.getName()); + changed = true; + } + + // Use channelType as description for quick identification in lists + String desc = config.getChannelType() != null + ? config.getChannelType() + " integration" + : null; + if (desc != null && !desc.equals(descriptor.getDescription())) { + descriptor.setDescription(desc); + changed = true; + } + + if (changed) { + documentDescriptorStore.setDescriptor( + resourceId, currentResourceId.getVersion(), descriptor); + } + } catch (Exception e) { + LOG.warnf(e, "Failed to sync channel descriptor for id=%s", + sanitizeForLog(resourceId)); + } + } + + private static String sanitizeForLog(String value) { + if (value == null) + return null; + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\r' || c == '\n' || c == '\t' || c < 0x20 || c == 0x7F) { + sb.append('_'); + } else { + sb.append(c); + } + } + return sb.toString(); + } +} From 53e82240a7e3a83ce698b68554be5fd7d7b47ca7 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 10:07:32 +0200 Subject: [PATCH 02/35] feat(channels): add ChannelTargetRouter with colon-required trigger matching Platform-agnostic router replacing SlackChannelRouter: - Colon-required trigger syntax (architect: question) - Thread-target locking (prevents mid-thread target switching) - New-style config resolution from ChannelIntegrationConfiguration - Legacy ChannelConnector fallback (strict: new config wins per channelId) - 23 unit tests covering matching, aliases, help, edge cases, thread locking --- .../channels/ChannelTargetRouter.java | 437 ++++++++++++++++++ .../channels/ChannelTargetRouterTest.java | 348 ++++++++++++++ 2 files changed, 785 insertions(+) create mode 100644 src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java create mode 100644 src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java new file mode 100644 index 000000000..ba2795379 --- /dev/null +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -0,0 +1,437 @@ +package ai.labs.eddi.integrations.channels; + +import ai.labs.eddi.configs.agents.IRestAgentStore; +import ai.labs.eddi.configs.agents.model.AgentConfiguration; +import ai.labs.eddi.configs.agents.model.AgentConfiguration.ChannelConnector; +import ai.labs.eddi.configs.channels.IChannelIntegrationStore; +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.configs.channels.model.ChannelTarget; +import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; +import ai.labs.eddi.datastore.IResourceStore; +import ai.labs.eddi.engine.api.IRestAgentAdministration; +import ai.labs.eddi.engine.model.AgentDeploymentStatus; +import ai.labs.eddi.engine.model.Deployment; +import ai.labs.eddi.secrets.SecretResolver; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import static ai.labs.eddi.utils.RestUtilities.extractResourceId; + +/** + * Platform-agnostic target router for channel integrations. Resolves incoming + * channel messages to the correct {@link ChannelTarget} based on configured + * trigger keywords (colon-required syntax: {@code keyword: message}). + *

+ * Replaces {@code SlackChannelRouter} and is shared across all platform + * adapters (Slack, Teams, Discord). + *

+ * Fallback rule: If any {@code ChannelIntegrationConfiguration} matches + * a channelId, ALL legacy {@code ChannelConnector} entries for that channel are + * ignored. Legacy entries only activate for channels with zero new-style + * coverage. + * + * @since 6.1.0 + */ +@ApplicationScoped +public class ChannelTargetRouter { + + private static final Logger LOGGER = Logger.getLogger(ChannelTargetRouter.class); + private static final long REFRESH_INTERVAL_MS = 60_000; // 1 minute + private static final String CHANNEL_TYPE_SLACK = "slack"; + + private final IChannelIntegrationStore channelStore; + private final IDocumentDescriptorStore descriptorStore; + private final IRestAgentAdministration agentAdmin; + private final IRestAgentStore agentStore; + private final SecretResolver secretResolver; + + // ─── Cached state (atomic reference swap) ────────────────────────────────── + + /** + * channelType:channelId → ChannelIntegrationConfiguration (resolved secrets). + */ + private volatile Map integrationMap = Map.of(); + + /** All unique signing secrets for Slack (from both new + legacy configs). */ + private volatile Set slackSigningSecrets = Set.of(); + + /** Legacy channelId → LegacyTarget for backward compat. */ + private volatile Map legacyMap = Map.of(); + + /** Channel IDs covered by new-style ChannelIntegrationConfiguration. */ + private volatile Set newStyleChannelIds = Set.of(); + + private volatile long lastRefreshTime = 0; + private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); + + /** Thread → locked target (prevents mid-thread target switching). */ + private final Map threadTargetLock = new ConcurrentHashMap<>(); + + @Inject + public ChannelTargetRouter(IChannelIntegrationStore channelStore, + IDocumentDescriptorStore descriptorStore, + IRestAgentAdministration agentAdmin, + IRestAgentStore agentStore, + SecretResolver secretResolver) { + this.channelStore = channelStore; + this.descriptorStore = descriptorStore; + this.agentAdmin = agentAdmin; + this.agentStore = agentStore; + this.secretResolver = secretResolver; + } + + // ─── Public API ──────────────────────────────────────────────────────────── + + /** + * Resolve a target for a fresh message (not a thread reply). Scans for a + * colon-delimited trigger keyword at the start of the message. + * + * @param channelType + * platform type (e.g., "slack") + * @param platformChannelId + * the platform-specific channel ID + * @param messageText + * the user's message (bot mention already stripped) + * @return resolved target, or {@code null} if the message is "help" or no + * integration covers this channel + */ + public ResolvedTarget resolveTarget(String channelType, String platformChannelId, + String messageText) { + refreshIfNeeded(); + + // 1. Try new-style ChannelIntegrationConfiguration + String key = channelType + ":" + platformChannelId; + ChannelIntegrationConfiguration integration = integrationMap.get(key); + if (integration != null) { + return resolveFromIntegration(integration, messageText); + } + + // 2. Fallback: legacy ChannelConnector (only if no new-style config covers this + // channel) + if (CHANNEL_TYPE_SLACK.equals(channelType)) { + LegacyTarget legacy = legacyMap.get(platformChannelId); + if (legacy != null) { + return new ResolvedTarget(legacy.toChannelTarget(), messageText, null, + legacy.botToken(), legacy.signingSecret()); + } + } + + return null; // No integration for this channel + } + + /** + * Resolve the target for a thread reply using the thread→target lock. + * + * @return the locked target, or {@code null} if no lock exists for this thread + */ + public ResolvedTarget resolveThreadTarget(String channelType, String platformChannelId, + String threadTs) { + ChannelTarget locked = threadTargetLock.get(threadTs); + if (locked == null) { + return null; + } + + refreshIfNeeded(); + String key = channelType + ":" + platformChannelId; + ChannelIntegrationConfiguration integration = integrationMap.get(key); + + return new ResolvedTarget(locked, null, integration, null, null); + } + + /** + * Lock a target for a thread. Subsequent messages in this thread will always + * route to the same target, ignoring trigger keywords. + */ + public void lockThreadTarget(String threadTs, ChannelTarget target) { + threadTargetLock.put(threadTs, target); + } + + /** + * Get all signing secrets for a given platform type. Used by webhook signature + * verifiers. + */ + public Set getSigningSecrets(String channelType) { + refreshIfNeeded(); + if (CHANNEL_TYPE_SLACK.equals(channelType)) { + return slackSigningSecrets; + } + return Set.of(); + } + + /** + * Get the integration config for a specific channel. Returns empty if no + * new-style config covers this channel. + */ + public Optional getIntegration(String channelType, + String platformChannelId) { + refreshIfNeeded(); + return Optional.ofNullable(integrationMap.get(channelType + ":" + platformChannelId)); + } + + /** + * Check if any channel integrations are configured (new or legacy). + */ + public boolean hasAnyChannels(String channelType) { + refreshIfNeeded(); + if (CHANNEL_TYPE_SLACK.equals(channelType)) { + return integrationMap.keySet().stream().anyMatch(k -> k.startsWith("slack:")) + || !legacyMap.isEmpty(); + } + return integrationMap.keySet().stream().anyMatch(k -> k.startsWith(channelType + ":")); + } + + // ─── Trigger matching ────────────────────────────────────────────────────── + + /** + * Resolve a target from a new-style integration config. + *

+ * Matching rule (colon required): + *

    + *
  1. If message equals "help" (no colon) → return null (signal for help)
  2. + *
  3. If message contains ":" → check if text before first ":" matches a + * trigger
  4. + *
  5. Match found → return that target with stripped message (text after + * colon)
  6. + *
  7. No match → return default target with full message
  8. + *
+ */ + ResolvedTarget resolveFromIntegration(ChannelIntegrationConfiguration integration, + String messageText) { + if (messageText == null || messageText.isBlank()) { + return null; // Empty → help + } + + String trimmed = messageText.trim(); + + // "help" → signal help + if ("help".equalsIgnoreCase(trimmed)) { + return null; + } + + // Check for colon-delimited trigger + int colonIdx = trimmed.indexOf(':'); + if (colonIdx > 0) { + String candidateTrigger = trimmed.substring(0, colonIdx).trim().toLowerCase(); + String remainder = trimmed.substring(colonIdx + 1).trim(); + + for (ChannelTarget target : integration.getTargets()) { + if (target.getTriggers() != null) { + for (String trigger : target.getTriggers()) { + if (trigger.toLowerCase().trim().equals(candidateTrigger)) { + return new ResolvedTarget(target, remainder, integration, + null, null); + } + } + } + } + } + + // No trigger match → default target, full message + ChannelTarget defaultTarget = findDefaultTarget(integration); + if (defaultTarget != null) { + return new ResolvedTarget(defaultTarget, trimmed, integration, null, null); + } + + LOGGER.warnf("No default target found for integration '%s'", integration.getName()); + return null; + } + + private ChannelTarget findDefaultTarget(ChannelIntegrationConfiguration integration) { + String defaultName = integration.getDefaultTargetName(); + if (defaultName == null) + return null; + return integration.getTargets().stream() + .filter(t -> t.getName() != null + && t.getName().equalsIgnoreCase(defaultName)) + .findFirst() + .orElse(null); + } + + // ─── Refresh ─────────────────────────────────────────────────────────────── + + private void refreshIfNeeded() { + long now = System.currentTimeMillis(); + if (now - lastRefreshTime < REFRESH_INTERVAL_MS) { + return; + } + if (!refreshInProgress.compareAndSet(false, true)) { + return; + } + try { + refreshInternal(); + lastRefreshTime = now; + } catch (Exception e) { + LOGGER.warnf("Failed to refresh channel target router: %s", e.getMessage()); + lastRefreshTime = now; // Avoid hammering on repeated failures + } finally { + refreshInProgress.set(false); + } + } + + private void refreshInternal() { + var newIntegrationMap = new HashMap(); + var newSigningSecrets = new HashSet(); + var coveredChannelIds = new HashSet(); + + // 1. Load new-style ChannelIntegrationConfigurations + try { + var descriptors = descriptorStore.readDescriptors("ai.labs.channel", + "", 0, 1000, false); + for (var descriptor : descriptors) { + try { + var resId = extractResourceId(descriptor.getResource()); + var config = channelStore.read(resId.getId(), + resId.getVersion()); + if (config != null && config.getChannelType() != null + && config.getPlatformConfig() != null) { + + // Resolve secrets in platformConfig + String channelId = config.getPlatformConfig().get("channelId"); + if (channelId != null && !channelId.isBlank()) { + resolvePlatformSecrets(config); + String key = config.getChannelType().toLowerCase() + ":" + channelId; + newIntegrationMap.put(key, config); + coveredChannelIds.add(channelId); + + // Collect signing secrets for Slack + if (CHANNEL_TYPE_SLACK.equals( + config.getChannelType().toLowerCase())) { + String ss = config.getPlatformConfig().get("signingSecret"); + if (ss != null && !ss.isBlank()) { + newSigningSecrets.add(ss); + } + } + } + } + } catch (Exception e) { + LOGGER.debugf("Skipping channel config: %s", e.getMessage()); + } + } + } catch (Exception e) { + LOGGER.warnf("Failed to load channel integration configs: %s", e.getMessage()); + } + + // 2. Load legacy ChannelConnector entries (backward compat) + var newLegacyMap = new HashMap(); + try { + List statuses = agentAdmin.getDeploymentStatuses( + Deployment.Environment.production); + for (AgentDeploymentStatus status : statuses) { + if (status.getDescriptor() == null || status.getDescriptor().isDeleted()) { + continue; + } + String agentId = status.getAgentId(); + try { + AgentConfiguration agentConfig = agentStore.readAgent( + agentId, status.getAgentVersion()); + if (agentConfig != null && agentConfig.getChannels() != null) { + for (ChannelConnector connector : agentConfig.getChannels()) { + if (connector.getType() != null + && connector.getType().toString() + .equalsIgnoreCase(CHANNEL_TYPE_SLACK) + && connector.getConfig() != null) { + + String chId = connector.getConfig().get("channelId"); + + // Strict rule: new config wins, skip legacy + if (chId != null && !coveredChannelIds.contains(chId)) { + String bt = resolveSecret( + connector.getConfig().get("botToken")); + String ss = resolveSecret( + connector.getConfig().get("signingSecret")); + String gid = connector.getConfig().get("groupId"); + newLegacyMap.put(chId, + new LegacyTarget(agentId, bt, ss, + gid != null && !gid.isBlank() ? gid : null)); + if (ss != null && !ss.isBlank()) { + newSigningSecrets.add(ss); + } + } + } + } + } + } catch (Exception e) { + LOGGER.debugf("Skipping agent %s for legacy channel scan: %s", + agentId, e.getMessage()); + } + } + } catch (Exception e) { + LOGGER.warnf("Failed to scan legacy ChannelConnectors: %s", e.getMessage()); + } + + // Atomic swap + integrationMap = Map.copyOf(newIntegrationMap); + legacyMap = Map.copyOf(newLegacyMap); + slackSigningSecrets = Set.copyOf(newSigningSecrets); + newStyleChannelIds = Set.copyOf(coveredChannelIds); + + LOGGER.infof("Channel target router refreshed: %d integrations, %d legacy, %d signing secrets", + newIntegrationMap.size(), newLegacyMap.size(), newSigningSecrets.size()); + } + + private void resolvePlatformSecrets(ChannelIntegrationConfiguration config) { + Map resolved = new HashMap<>(); + for (var entry : config.getPlatformConfig().entrySet()) { + resolved.put(entry.getKey(), resolveSecret(entry.getValue())); + } + config.setPlatformConfig(resolved); + } + + private String resolveSecret(String value) { + if (value == null || value.isBlank()) + return null; + try { + return secretResolver.resolveValue(value); + } catch (Exception e) { + LOGGER.warnf("Failed to resolve secret: %s", e.getMessage()); + return null; + } + } + + // ─── Inner types ─────────────────────────────────────────────────────────── + + /** + * Result of target resolution — includes the matched target, the message with + * trigger keyword stripped, and (optionally) resolved credentials. + */ + public record ResolvedTarget( + ChannelTarget target, + String strippedMessage, + ChannelIntegrationConfiguration integration, + String legacyBotToken, + String legacySigningSecret) { + /** Get bot token — from integration or legacy. */ + public String botToken() { + if (integration != null && integration.getPlatformConfig() != null) { + return integration.getPlatformConfig().get("botToken"); + } + return legacyBotToken; + } + + /** Get signing secret — from integration or legacy. */ + public String signingSecret() { + if (integration != null && integration.getPlatformConfig() != null) { + return integration.getPlatformConfig().get("signingSecret"); + } + return legacySigningSecret; + } + } + + /** + * Backward-compatible representation of a legacy ChannelConnector entry. + */ + record LegacyTarget(String agentId, String botToken, String signingSecret, String groupId) { + ChannelTarget toChannelTarget() { + var target = new ChannelTarget(); + target.setName("default"); + target.setType(ChannelTarget.TargetType.AGENT); + target.setTargetId(agentId); + return target; + } + } +} diff --git a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java new file mode 100644 index 000000000..02503193f --- /dev/null +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java @@ -0,0 +1,348 @@ +package ai.labs.eddi.integrations.channels; + +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.configs.channels.model.ChannelTarget; +import ai.labs.eddi.integrations.channels.ChannelTargetRouter.ResolvedTarget; +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 java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for trigger matching, default fallback, help detection, colon + * requirement, and thread target locking in {@link ChannelTargetRouter}. + *

+ * These tests exercise the {@code resolveFromIntegration} logic directly + * without needing a running database or agent deployment infrastructure. + */ +class ChannelTargetRouterTest { + + private ChannelTargetRouter router; + private ChannelIntegrationConfiguration integration; + + @BeforeEach + void setUp() { + // Router is instantiated with null dependencies — we only test the + // matching logic (resolveFromIntegration) which doesn't hit any stores. + router = new ChannelTargetRouter(null, null, null, null, null); + + integration = new ChannelIntegrationConfiguration(); + integration.setName("Test Hub"); + integration.setChannelType("slack"); + integration.setDefaultTargetName("architect"); + + var architect = new ChannelTarget(); + architect.setName("architect"); + architect.setTriggers(List.of("architect", "arch")); + architect.setType(ChannelTarget.TargetType.AGENT); + architect.setTargetId("agent-arch-id"); + + var security = new ChannelTarget(); + security.setName("security"); + security.setTriggers(List.of("security", "sec", "infosec")); + security.setType(ChannelTarget.TargetType.AGENT); + security.setTargetId("agent-sec-id"); + + var review = new ChannelTarget(); + review.setName("review"); + review.setTriggers(List.of("review", "review-panel")); + review.setType(ChannelTarget.TargetType.GROUP); + review.setTargetId("group-review-id"); + + integration.setTargets(List.of(architect, security, review)); + } + + // ─── Colon-required trigger matching ─────────────────────────────────────── + + @Nested + @DisplayName("Trigger matching (colon required)") + class TriggerMatching { + + @Test + @DisplayName("architect: question → matches architect target") + void matchesExplicitTriggerWithColon() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "architect: how do I deploy?"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + assertEquals("how do I deploy?", result.strippedMessage()); + assertEquals("agent-arch-id", result.target().getTargetId()); + } + + @Test + @DisplayName("sec: is this safe → matches security via alias") + void matchesTriggerAlias() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "sec: is this endpoint safe?"); + + assertNotNull(result); + assertEquals("security", result.target().getName()); + assertEquals("is this endpoint safe?", result.strippedMessage()); + } + + @Test + @DisplayName("review: should we migrate → matches group target") + void matchesGroupTarget() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "review: should we migrate to gRPC?"); + + assertNotNull(result); + assertEquals("review", result.target().getName()); + assertEquals(ChannelTarget.TargetType.GROUP, result.target().getType()); + assertEquals("group-review-id", result.target().getTargetId()); + } + + @Test + @DisplayName("ARCHITECT: question → case-insensitive match") + void matchesCaseInsensitive() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "ARCHITECT: deploy question"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + } + + @Test + @DisplayName("arch: question → matches via short alias") + void matchesShortAlias() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "arch: question about patterns"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + } + + @Test + @DisplayName("review-panel: question → matches hyphenated trigger") + void matchesHyphenatedTrigger() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "review-panel: evaluate this design"); + + assertNotNull(result); + assertEquals("review", result.target().getName()); + } + } + + // ─── Colon-required: no match without colon ──────────────────────────────── + + @Nested + @DisplayName("No colon → falls through to default") + class NoColonFallthrough { + + @Test + @DisplayName("architect how do I deploy → no colon, falls to default") + void noColonFallsToDefault() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "architect how do I deploy?"); + + assertNotNull(result); + // Falls through to default target (architect in this case, so same target but + // the important thing is the full message is preserved) + assertEquals("architect", result.target().getName()); + assertEquals("architect how do I deploy?", result.strippedMessage()); + } + + @Test + @DisplayName("architect diagrams are useful → no false positive without colon") + void noFalsePositiveWithoutColon() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "architect diagrams are useful"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + // Full message preserved — not stripped + assertEquals("architect diagrams are useful", result.strippedMessage()); + } + + @Test + @DisplayName("plain question → routes to default target") + void plainQuestionDefaultTarget() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "how do I deploy to production?"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + assertEquals("how do I deploy to production?", result.strippedMessage()); + } + } + + // ─── Unknown trigger with colon ──────────────────────────────────────────── + + @Nested + @DisplayName("Unknown trigger keyword") + class UnknownTrigger { + + @Test + @DisplayName("unknown: question → falls to default with full message") + void unknownTriggerFallsToDefault() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "unknown: some question"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + // Full message including "unknown:" preserved + assertEquals("unknown: some question", result.strippedMessage()); + } + } + + // ─── Help detection ──────────────────────────────────────────────────────── + + @Nested + @DisplayName("Help detection") + class HelpDetection { + + @Test + @DisplayName("help → returns null (signal for help)") + void helpReturnsNull() { + assertNull(router.resolveFromIntegration(integration, "help")); + } + + @Test + @DisplayName("HELP → case-insensitive help") + void helpCaseInsensitive() { + assertNull(router.resolveFromIntegration(integration, "HELP")); + } + + @Test + @DisplayName(" help → trimmed help") + void helpTrimmed() { + assertNull(router.resolveFromIntegration(integration, " help ")); + } + + @Test + @DisplayName("empty message → returns null") + void emptyMessageReturnsNull() { + assertNull(router.resolveFromIntegration(integration, "")); + } + + @Test + @DisplayName("null message → returns null") + void nullMessageReturnsNull() { + assertNull(router.resolveFromIntegration(integration, null)); + } + + @Test + @DisplayName("blank message → returns null") + void blankMessageReturnsNull() { + assertNull(router.resolveFromIntegration(integration, " ")); + } + } + + // ─── Thread target locking ───────────────────────────────────────────────── + + @Nested + @DisplayName("Thread target locking") + class ThreadTargetLocking { + + @Test + @DisplayName("locked target is returned for thread") + void lockedTargetReturned() { + var target = new ChannelTarget(); + target.setName("architect"); + target.setTargetId("agent-arch-id"); + + router.lockThreadTarget("1713400000.123456", target); + + ResolvedTarget result = router.resolveThreadTarget("slack", "C07TEST", + "1713400000.123456"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + } + + @Test + @DisplayName("unlocked thread returns null") + void unlockedThreadReturnsNull() { + ResolvedTarget result = router.resolveThreadTarget("slack", "C07TEST", + "9999999999.999999"); + + assertNull(result); + } + + @Test + @DisplayName("different threads have independent locks") + void independentThreadLocks() { + var arch = new ChannelTarget(); + arch.setName("architect"); + + var sec = new ChannelTarget(); + sec.setName("security"); + + router.lockThreadTarget("thread-1", arch); + router.lockThreadTarget("thread-2", sec); + + assertEquals("architect", + router.resolveThreadTarget("slack", "C07TEST", "thread-1") + .target().getName()); + assertEquals("security", + router.resolveThreadTarget("slack", "C07TEST", "thread-2") + .target().getName()); + } + } + + // ─── Edge cases ──────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Edge cases") + class EdgeCases { + + @Test + @DisplayName("message with multiple colons → only first colon counts") + void multipleColons() { + ResolvedTarget result = router.resolveFromIntegration(integration, + "architect: what about http://example.com?"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + assertEquals("what about http://example.com?", result.strippedMessage()); + } + + @Test + @DisplayName("trigger with leading/trailing spaces → trimmed") + void triggerWithSpaces() { + ResolvedTarget result = router.resolveFromIntegration(integration, + " architect : how do I deploy?"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + } + + @Test + @DisplayName("colon at start of message → no trigger, default") + void colonAtStart() { + ResolvedTarget result = router.resolveFromIntegration(integration, + ": some question"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + assertEquals(": some question", result.strippedMessage()); + } + + @Test + @DisplayName("single-target integration → always resolves to that target") + void singleTarget() { + var simpleIntegration = new ChannelIntegrationConfiguration(); + simpleIntegration.setDefaultTargetName("support"); + + var support = new ChannelTarget(); + support.setName("support"); + support.setTriggers(List.of("support")); + support.setType(ChannelTarget.TargetType.AGENT); + support.setTargetId("agent-support-id"); + + simpleIntegration.setTargets(List.of(support)); + + ResolvedTarget result = router.resolveFromIntegration(simpleIntegration, + "I need help with my order"); + + assertNotNull(result); + assertEquals("support", result.target().getName()); + assertEquals("I need help with my order", result.strippedMessage()); + } + } +} From 9f2b3306f826a45f5c518186eb6b085eb1ba1e26 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 10:11:03 +0200 Subject: [PATCH 03/35] docs: add conversation cancel & lifecycle control plan Covers group discussion and regular conversation cancel/stop. Designed with HITL extension points (Phase 9b) built in. Includes DiscussionControlToken, safe-point analysis, cascading abort, and API design for POST .../cancel endpoints. --- planning/conversation-cancel-plan.md | 311 +++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 planning/conversation-cancel-plan.md diff --git a/planning/conversation-cancel-plan.md b/planning/conversation-cancel-plan.md new file mode 100644 index 000000000..39591ef05 --- /dev/null +++ b/planning/conversation-cancel-plan.md @@ -0,0 +1,311 @@ +# Conversation Cancellation & Lifecycle Control + +> **Scope**: Cancel/stop for group discussions and regular conversations. +> **HITL prerequisite**: This plan explicitly designs the control mechanism to be extensible for HITL pause/resume/approve without refactoring. + +--- + +## 1. Problem Statement + +Currently, neither group discussions nor regular agent conversations can be stopped mid-execution. + +- **Group discussions**: The `executeDiscussion()` loop runs all phases to completion. SSE client disconnect silently drops events but the backend keeps calling LLM agents — wasting tokens and compute. +- **Regular conversations**: The `Conversation.say()` method submits work via `ConversationCoordinator.submitInOrder()` which runs the full lifecycle pipeline (parser → rules → LLM → output → property setter). There is no way to externally cancel an in-flight turn. +- **Nested groups**: A GROUP-type member triggers a recursive `discuss()` call. Cancelling the parent must cascade to all child discussions. + +### What already exists + +| Mechanism | Where | Purpose | +|-----------|-------|---------| +| `ConversationStopException` | `ILifecycleManager` | Stops a regular conversation's lifecycle — thrown by specific tasks, caught in `Conversation.executeConversationStep()` | +| `STOP_CONVERSATION` action | `IConversation` | String constant that triggers `endConversation()` via the actions system | +| `ConversationState.EXECUTION_INTERRUPTED` | `ConversationState` enum | Already exists as a state for interrupted conversations | +| `CompletableFuture.cancel(true)` | `executeParallelPhase()` | Used for timeout — cancels parallel agent calls | +| `eventSink.isClosed()` check | `RestGroupConversation.sendEvent()` | Detects SSE client disconnect but does **not** propagate to execution loop | + +--- + +## 2. Design: `DiscussionControlToken` + +A shared, thread-safe control object that the execution loop checks at **safe points**. Designed from the start to support HITL operations beyond simple cancel. + +### 2.1 Control Signal Enum + +```java +public enum ControlSignal { + /** Normal execution — continue to next speaker/phase */ + CONTINUE, + + /** Graceful stop — finish current speaker's turn, then stop before the next speaker */ + CANCEL_GRACEFUL, + + /** Immediate stop — attempt to interrupt the current LLM call */ + CANCEL_IMMEDIATE, + + // --- HITL extensions (Phase 9b, not implemented now) --- + // PAUSE, // Stop after current speaker, mark as PAUSED (resumable) + // AWAIT_APPROVAL, // Block until human approves/rejects the last response +} +``` + +### 2.2 Token Class + +```java +public class DiscussionControlToken { + private final AtomicReference signal = new AtomicReference<>(ControlSignal.CONTINUE); + + /** Set the control signal. Thread-safe. */ + public void setSignal(ControlSignal signal) { + this.signal.set(signal); + } + + /** Read the current signal. Thread-safe. */ + public ControlSignal getSignal() { + return signal.get(); + } + + /** Convenience: is any cancel variant active? */ + public boolean isCancelled() { + var s = signal.get(); + return s == ControlSignal.CANCEL_GRACEFUL || s == ControlSignal.CANCEL_IMMEDIATE; + } + + // --- HITL extension point (Phase 9b) --- + // public boolean isPaused() { return signal.get() == ControlSignal.PAUSE; } + // public CompletableFuture awaitApproval() { ... } +} +``` + +### 2.3 Token Registry + +```java +// In GroupConversationService +private final ConcurrentMap activeTokens = new ConcurrentHashMap<>(); +``` + +- Token is created when `startAndDiscussAsync()` starts, keyed by `conversationId` +- Token is removed in the `finally` block of `executeDiscussion()` +- `cancelDiscussion(convId, mode)` looks up the token and sets the signal + +--- + +## 3. Safe Points + +### 3.1 Group Discussions + +The execution loop has clear phase/speaker boundaries. Safe points are: + +``` +executeDiscussion() + for each phase: ← ✅ Safe point: between phases + for each repeat: + for each speaker (sequential): ← ✅ Safe point: between speakers (GRACEFUL) + executeSpeakerTurn() ← ⚠️ IMMEDIATE: needs to interrupt the blocking call + for each speaker (parallel): + futures.get(timeout) ← ✅ Safe point: cancel remaining futures +``` + +**Graceful cancel check locations** (checked at each `✅`): + +1. **Top of phase loop** (line ~208) — before `executeSequentialPhase()` / `executeParallelPhase()` +2. **Top of speaker loop** in `executeSequentialPhase()` (line ~385) — before each `executeAgentTurn()` +3. **After each future** in `executeParallelPhase()` (line ~440) — cancel remaining futures if signal received + +**Immediate cancel** — additionally: + +4. **In `executeAgentTurn()`** (line ~565) — the `CompletableFuture responseFuture` has a blocking `.get(timeout)`. Interrupt the thread to abort the LLM HTTP call. The `cancel(true)` mechanism already exists for parallel timeout. + +### 3.2 Regular Conversations + +The lifecycle pipeline runs tasks sequentially via `ILifecycleManager.executeLifecycle()`. The existing `ConversationStopException` already provides the interrupt mechanism — we just need to trigger it externally. + +**Approach**: Add a `volatile boolean cancelled` flag to `IConversationMemory`. The `LifecycleManager` checks this **between lifecycle tasks** (between parser, rules, LLM, output steps). If cancelled, throw `ConversationStopException`. + +``` +executeLifecycle() + for each task: ← ✅ Safe point: between lifecycle tasks + if (memory.isCancelled()) throw new ConversationStopException(); + task.executeTask(memory); +``` + +This reuses the existing `ConversationStopException` handling in `Conversation.executeConversationStep()` (line ~295) which already catches this exception and completes gracefully. + +### 3.3 Cascading: Group → Agent → Sub-Group + +When a group is cancelled: + +1. The `DiscussionControlToken` signal is set +2. The phase loop breaks at the next safe point +3. **Currently-executing agent turns** need cascading: + - `executeAgentTurn()` calls `conversationService.say()` which runs the lifecycle + - The agent's `ConversationMemory` needs a cancel flag too + - The group service sets `memory.setCancelled(true)` before checking the token +4. **Nested sub-groups** (`executeGroupMemberTurn()` calls `discuss()` recursively): + - Pass the parent's `DiscussionControlToken` to child discussions + - Child discussions inherit the cancel signal automatically + +--- + +## 4. API Design + +### 4.1 Group Discussion Cancel + +``` +POST /groups/{groupId}/conversations/{groupConversationId}/cancel +Content-Type: application/json + +{ + "mode": "GRACEFUL" // or "IMMEDIATE" +} +``` + +- **`GRACEFUL`** (default): Finish the current speaker's turn, then stop. Transcript is consistent. +- **`IMMEDIATE`**: Interrupt the current LLM call. The speaking agent's turn gets a SKIPPED entry. + +**Response**: `200 OK` if found and cancelled, `404` if conversation not found or already completed. + +**New state**: `GroupConversationState.CANCELLED` + +### 4.2 Regular Conversation Cancel + +``` +POST /agents/{agentId}/conversations/{conversationId}/cancel +``` + +No body needed — regular conversations have a single in-flight turn, so there's no graceful/immediate distinction. The lifecycle is interrupted between tasks via `ConversationStopException`. + +**Existing state**: `ConversationState.EXECUTION_INTERRUPTED` (already exists!) + +### 4.3 SSE Auto-Cancel (Group only) + +When the SSE client disconnects (`eventSink.isClosed()`), the REST layer automatically calls `cancelDiscussion(convId, GRACEFUL)`. This is the "user closed the browser tab" scenario. + +```java +// In RestGroupConversation.discussStreaming(), modify the listener: +@Override +public void onSpeakerStart(SpeakerStartEvent event) { + if (eventSink.isClosed()) { + groupConversationService.cancelDiscussion(gc.getId(), ControlSignal.CANCEL_GRACEFUL); + return; + } + sendEvent(eventSink, sse, EVENT_SPEAKER_START, toJson(event)); +} +``` + +--- + +## 5. State Transitions + +### Group Conversation + +``` +CREATED → IN_PROGRESS → SYNTHESIZING → COMPLETED + ↘ CANCELLED (via cancel) + ↘ FAILED (via error) + // HITL extensions (Phase 9b): + // → PAUSED → IN_PROGRESS (resume) + // → AWAITING_APPROVAL → IN_PROGRESS (approved) | CANCELLED (rejected) +``` + +### Regular Conversation + +``` +READY → IN_PROGRESS → READY (completed turn) + ↘ EXECUTION_INTERRUPTED (via cancel — already exists) + ↘ ERROR +``` + +--- + +## 6. HITL Extension Points + +The `DiscussionControlToken` is explicitly designed so HITL can be added **without modifying** the cancel implementation: + +| Cancel (this plan) | HITL (Phase 9b) | +|---|---| +| `CANCEL_GRACEFUL` signal | `PAUSE` signal — same check location, but saves state as PAUSED instead of CANCELLED | +| Token removed after discussion ends | Token kept alive for PAUSED conversations — allows resume | +| No human interaction needed | `AWAIT_APPROVAL` signal — blocks the execution thread until a human endpoint is called | +| `POST .../cancel` | `POST .../pause`, `POST .../resume`, `POST .../approve`, `POST .../reject` | + +**Key architectural guarantee**: The safe-point checking logic in the phase loop and lifecycle manager is **identical** for cancel and HITL. Adding HITL means: +1. Add new `ControlSignal` variants (`PAUSE`, `AWAIT_APPROVAL`) +2. Add corresponding state enum values (`PAUSED`, `AWAITING_APPROVAL`) +3. Add REST endpoints for human actions +4. In the safe-point check, handle new signals alongside the cancel ones + +No refactoring of the execution loop is needed. + +### HITL for Regular Conversations + +The `volatile boolean cancelled` on `IConversationMemory` would evolve to a `ControlSignal` field (or a dedicated `ConversationControlToken`). The lifecycle manager checks between tasks. For HITL, instead of throwing `ConversationStopException`, it would block on a `CompletableFuture` that resolves when the human approves/rejects. + +--- + +## 7. Implementation Plan + +### Phase A: Backend Core (EDDI repo) + +| # | File | Change | +|---|------|--------| +| A1 | `DiscussionControlToken.java` [NEW] | Token class with `AtomicReference` | +| A2 | `ControlSignal.java` [NEW] | Enum: `CONTINUE`, `CANCEL_GRACEFUL`, `CANCEL_IMMEDIATE` | +| A3 | `GroupConversation.java` | Add `CANCELLED` to `GroupConversationState` enum | +| A4 | `GroupConversationService.java` | Add `activeTokens` map, create/remove token around execution, check signal at safe points | +| A5 | `IGroupConversationService.java` | Add `cancelDiscussion(String convId, ControlSignal mode)` | +| A6 | `IRestGroupConversation.java` | Add `POST /{convId}/cancel` endpoint | +| A7 | `RestGroupConversation.java` | Implement cancel endpoint + SSE auto-cancel on `eventSink.isClosed()` | + +### Phase B: Regular Conversation Cancel (EDDI repo) + +| # | File | Change | +|---|------|--------| +| B1 | `IConversationMemory.java` | Add `setCancelled(boolean)` / `isCancelled()` | +| B2 | `ConversationMemory.java` | Implement with `volatile boolean` | +| B3 | `LifecycleManager.java` (impl) | Check `memory.isCancelled()` between task executions, throw `ConversationStopException` | +| B4 | `IConversationService.java` | Add `cancelConversation(String convId)` | +| B5 | `ConversationService.java` | Implement: find active conversation memory, set cancelled | +| B6 | REST endpoint | Add `POST /agents/{agentId}/conversations/{convId}/cancel` | + +### Phase C: Frontend (EDDI-Manager repo) + +| # | File | Change | +|---|------|--------| +| C1 | `groups.ts` | Add `cancelGroupDiscussion(groupId, convId, mode)` API function | +| C2 | `use-group-discussion-stream.ts` | Wire `abortStream()` to call the cancel API before disconnecting SSE | +| C3 | `group-detail.tsx` | Add Stop/Cancel button (re-introduce, now backed by real backend cancel) | +| C4 | Chat components | Add stop button to regular conversation chat during streaming | + +### Phase D: Tests + +| # | Test | +|---|------| +| D1 | Unit test: `DiscussionControlToken` thread safety | +| D2 | Unit test: `GroupConversationService` cancel at phase boundary | +| D3 | Unit test: `GroupConversationService` cancel cascading to sub-group | +| D4 | Integration test: SSE auto-cancel on client disconnect | +| D5 | Unit test: Regular conversation cancel between lifecycle tasks | + +--- + +## 8. Open Questions + +1. **Cancel response body**: Should `POST .../cancel` return the partial `GroupConversation` (with transcript so far) or just `200 OK`? + - *Recommendation*: Return the saved `GroupConversation` — the client can display the partial transcript. + +2. **Idempotency**: What if cancel is called on an already-completed conversation? + - *Recommendation*: Return `200 OK` with the existing conversation — no error. + +3. **Metrics**: Should cancelled discussions count as failures? + - *Recommendation*: New counter `eddi_group_discussion_cancelled_count` (separate from failure). + +4. **Nested group cancel**: When a parent group is cancelled, should the child sub-group's conversation also be persisted with `CANCELLED` state? + - *Recommendation*: Yes — the parent's token cascades to the child, so the child also gets `CANCELLED`. + +--- + +## 9. Relationship to Other Plans + +- **Agentic Improvements Phase 9b (HITL)**: This plan provides the prerequisite infrastructure. HITL adds `PAUSE`/`AWAIT_APPROVAL` signals to the same token mechanism. +- **Multi-tenancy**: Cancel endpoints inherit the same auth/tenant context as other conversation endpoints — no special handling needed. +- **Observability**: New metrics (`cancelled_count`) and trace spans for cancel events. From a69d5fa6d7aafecd1c5a67b57aa05f1022951f7f Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 10:16:26 +0200 Subject: [PATCH 04/35] refactor(slack): replace SlackChannelRouter with ChannelTargetRouter - SlackEventHandler now uses ChannelTargetRouter for all routing - Removed GROUP_PREFIX pattern (group: magic prefix), replaced by config triggers - Added target-based routing: AGENT and GROUP types routed via switch - Thread target locking: first message locks target for entire thread - postHelp() lists available targets with trigger keywords - postMessage() resolves bot token from ResolvedTarget or router fallback - getOrCreateConversation() uses integration-aware intent keys - RestSlackWebhook uses ChannelTargetRouter.getSigningSecrets() - SlackChannelRouter.java preserved (not deleted yet, tests reference it) --- .../integrations/slack/SlackEventHandler.java | 211 +++++++++++------- .../slack/rest/RestSlackWebhook.java | 14 +- 2 files changed, 140 insertions(+), 85 deletions(-) diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java index 6ea9be7d6..69508f10a 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -1,5 +1,6 @@ package ai.labs.eddi.integrations.slack; +import ai.labs.eddi.configs.channels.model.ChannelTarget; import ai.labs.eddi.engine.caching.ICache; import ai.labs.eddi.engine.caching.ICacheFactory; import ai.labs.eddi.engine.api.IConversationService; @@ -10,6 +11,8 @@ import ai.labs.eddi.engine.model.Deployment; import ai.labs.eddi.engine.triggermanagement.IUserConversationStore; import ai.labs.eddi.engine.triggermanagement.model.UserConversation; +import ai.labs.eddi.integrations.channels.ChannelTargetRouter; +import ai.labs.eddi.integrations.channels.ChannelTargetRouter.ResolvedTarget; import ai.labs.eddi.datastore.IResourceStore; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; @@ -25,20 +28,23 @@ /** * Core Slack event handler. Receives parsed events from * {@link ai.labs.eddi.integrations.slack.rest.RestSlackWebhook}, routes them to - * the correct EDDI agent (via {@link SlackChannelRouter}), manages conversation - * state (via {@link IUserConversationStore}), and posts responses back to Slack - * (via {@link SlackWebApiClient}). + * the correct EDDI target (agent or group) via {@link ChannelTargetRouter}, + * manages conversation state (via {@link IUserConversationStore}), and posts + * responses back to Slack (via {@link SlackWebApiClient}). *

- * All credentials (bot tokens, signing secrets) are resolved per-agent from - * {@link SlackChannelRouter.SlackCredentials} — no server-level credentials. + * All credentials (bot tokens, signing secrets) are resolved from + * {@link ChannelTargetRouter} — either from new-style + * {@code ChannelIntegrationConfiguration} or legacy {@code ChannelConnector}. *

* Key behaviors: *

    *
  • De-duplicates events by {@code event_id} (Slack retries up to 3x)
  • *
  • Filters out bot's own messages to prevent infinite loops
  • *
  • Strips bot mention prefix from message text
  • - *
  • Detects {@code group:} prefix to trigger multi-agent group - * discussions
  • + *
  • Routes via colon-delimited trigger keywords (e.g., + * {@code architect:})
  • + *
  • Thread target locking — first message locks the target for the + * thread
  • *
  • Detects replies in agent threads to route context-aware follow-ups
  • *
  • Maps Slack threads → EDDI conversations via IUserConversationStore
  • *
  • Processes async to meet Slack's 3-second response requirement
  • @@ -58,11 +64,8 @@ public class SlackEventHandler { /** Maximum Slack message length (safe limit under 4000). */ private static final int MAX_SLACK_MESSAGE_LENGTH = 3900; - /** Pattern for group discussion trigger: "group: question" */ - private static final Pattern GROUP_PREFIX = Pattern.compile("^group:\\s*(.+)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); - private final SlackIntegrationConfig config; - private final SlackChannelRouter channelRouter; + private final ChannelTargetRouter channelTargetRouter; private final SlackWebApiClient slackApi; private final IConversationService conversationService; private final IGroupConversationService groupConversationService; @@ -84,14 +87,14 @@ public class SlackEventHandler { @Inject public SlackEventHandler(SlackIntegrationConfig config, - SlackChannelRouter channelRouter, + ChannelTargetRouter channelTargetRouter, SlackWebApiClient slackApi, IConversationService conversationService, IGroupConversationService groupConversationService, IUserConversationStore userConversationStore, ICacheFactory cacheFactory) { this.config = config; - this.channelRouter = channelRouter; + this.channelTargetRouter = channelTargetRouter; this.slackApi = slackApi; this.conversationService = conversationService; this.groupConversationService = groupConversationService; @@ -176,78 +179,87 @@ private void handleEvent(Map event) throws Exception { // Strip bot mention prefix: "<@U0123BOTID> hello" → "hello" text = stripBotMention(text); + String threadTs = getThreadTs(event); + if (text.isBlank()) { - postMessage(channelId, getThreadTs(event), - "👋 Hi! Send me a message and I'll respond.\n" - + "_Tip: Use `group: your question` to start a multi-agent discussion._"); + postHelp(channelId, threadTs); return; } - String threadTs = getThreadTs(event); - - // 1. Check if this is a reply in an agent's thread (follow-up) + // 1. Check thread target lock (existing threads keep their target) String parentTs = (String) event.get("thread_ts"); - if (parentTs != null && tryHandleAgentFollowUp(parentTs, channelId, userId, text, threadTs)) { + ResolvedTarget resolved = null; + + if (parentTs != null) { + resolved = channelTargetRouter.resolveThreadTarget("slack", channelId, parentTs); + } + + // 2. Check group follow-up (thread root was a group discussion) + if (resolved == null && parentTs != null + && tryHandleAgentFollowUp(parentTs, channelId, userId, text, threadTs)) { return; } - // 2. Check for group discussion trigger: "group: question" - Matcher groupMatcher = GROUP_PREFIX.matcher(text); - if (groupMatcher.matches()) { - handleGroupDiscussion(channelId, userId, groupMatcher.group(1).trim(), threadTs); + // 3. Fresh resolution via ChannelTargetRouter + if (resolved == null) { + resolved = channelTargetRouter.resolveTarget("slack", channelId, text); + } + + if (resolved == null) { + postHelp(channelId, threadTs); return; } - // 3. Standard 1:1 agent conversation - handleAgentConversation(channelId, userId, text, threadTs); + // Lock target for this thread + if (threadTs != null) { + channelTargetRouter.lockThreadTarget(threadTs, resolved.target()); + } + + // Store resolved target for credential resolution in postMessage + currentResolvedTarget.set(resolved); + try { + switch (resolved.target().getType()) { + case AGENT -> handleAgentConversation(resolved, channelId, userId, threadTs); + case GROUP -> handleGroupDiscussion(resolved, channelId, userId, threadTs); + } + } finally { + currentResolvedTarget.remove(); + } } /** - * Handle a standard 1:1 agent conversation. + * Handle a standard 1:1 agent conversation routed via ChannelTargetRouter. */ - private void handleAgentConversation(String channelId, String userId, - String text, String threadTs) + private void handleAgentConversation(ResolvedTarget resolved, String channelId, + String userId, String threadTs) throws Exception { - Optional agentIdOpt = channelRouter.resolveAgentId(channelId); - if (agentIdOpt.isEmpty()) { - LOGGER.warnf("No agent mapped for Slack channel %s", channelId); - postMessage(channelId, threadTs, - "⚠️ No agent is configured for this channel. Please contact an administrator."); - return; - } + String agentId = resolved.target().getTargetId(); + String targetName = resolved.target().getName(); + String threadKey = threadTs != null ? threadTs : "main"; + + // Compose intent key for conversation tracking + String integrationId = resolved.integration() != null + ? resolved.integration().getName() + : "legacy"; + String intent = "channel:" + integrationId + ":" + targetName + ":" + threadKey; - String agentId = agentIdOpt.get(); - String conversationId = getOrCreateConversation(agentId, userId, channelId, threadTs); - String response = sendAndWait(conversationId, text); + String conversationId = getOrCreateConversation(agentId, userId, intent); + String response = sendAndWait(conversationId, resolved.strippedMessage()); postMessageChunked(channelId, threadTs, response); } // ─── Group Discussion ─── /** - * Handle a group discussion trigger. Resolves the group ID from the channel - * config, creates a {@link SlackGroupDiscussionListener}, and starts the - * discussion asynchronously. + * Handle a group discussion trigger routed via ChannelTargetRouter. */ - private void handleGroupDiscussion(String channelId, String userId, - String question, String threadTs) { - Optional groupIdOpt = channelRouter.resolveGroupId(channelId); - if (groupIdOpt.isEmpty()) { - postMessage(channelId, threadTs, - "⚠️ No group is configured for this channel.\n" - + "Add a ChannelConnector with `groupId` to your agent config."); - return; - } - - String groupId = groupIdOpt.get(); - - // Resolve bot token for this channel - Optional credsOpt = channelRouter.resolveCredentials(channelId); - String botToken = credsOpt.map(SlackChannelRouter.SlackCredentials::botToken).orElse(""); + private void handleGroupDiscussion(ResolvedTarget resolved, String channelId, + String userId, String threadTs) { + String groupId = resolved.target().getTargetId(); + String botToken = resolved.botToken(); - if (botToken.isEmpty()) { - LOGGER.errorf("No bot token configured for Slack channel %s — cannot run group discussion. " + - "Check the agent's ChannelConnector config and vault key resolution.", channelId); + if (botToken == null || botToken.isEmpty()) { + LOGGER.errorf("No bot token configured for Slack channel %s — cannot run group discussion.", channelId); return; } @@ -256,6 +268,7 @@ private void handleGroupDiscussion(String channelId, String userId, // Create the listener that streams discussion into Slack var listener = new SlackGroupDiscussionListener(slackApi, token, channelId, threadTs); + String question = resolved.strippedMessage(); try { LOGGER.infof("Starting group discussion in channel %s, group %s, question: %s", channelId, groupId, question.substring(0, Math.min(80, question.length()))); @@ -358,16 +371,12 @@ private static String truncate(String text, int maxLen) { /** * Map a Slack thread to an EDDI conversation. Uses - * {@link IUserConversationStore} with intent = "slack:{channelId}:{threadTs}" - * and userId = slackUserId. + * {@link IUserConversationStore} with intent key composed from integration + + * target + thread. */ private String getOrCreateConversation(String agentId, String slackUserId, - String channelId, String threadTs) + String intent) throws Exception { - // Use thread_ts for threaded conversations, channel for top-level - String threadKey = threadTs != null ? threadTs : "main"; - String intent = "slack:" + channelId + ":" + threadKey; - // Try existing — readUserConversation returns null when not found, // throws ResourceStoreException only on real DB errors (which should propagate) UserConversation existing = userConversationStore.readUserConversation(intent, slackUserId); @@ -378,8 +387,7 @@ private String getOrCreateConversation(String agentId, String slackUserId, // Create new conversation var result = conversationService.startConversation( Deployment.Environment.production, agentId, slackUserId, - Map.of("slackChannel", new Context(Context.ContextType.string, channelId), - "slackThread", new Context(Context.ContextType.string, threadKey))); + Map.of("channelIntent", new Context(Context.ContextType.string, intent))); // Store mapping var mapping = new UserConversation(intent, slackUserId, @@ -471,15 +479,30 @@ private void postMessageChunked(String channelId, String threadTs, String text) } /** - * Post a single message to Slack via the Web API, using the per-agent bot token - * resolved from the channel's ChannelConnector config. + * Thread-local storage for the current resolved target. Used by postMessage to + * resolve the bot token without passing it through every method. + */ + private final ThreadLocal currentResolvedTarget = new ThreadLocal<>(); + + /** + * Post a single message to Slack via the Web API, using the bot token from the + * current resolved target or from the channel target router. */ private void postMessage(String channelId, String threadTs, String text) { - // Resolve bot token for this channel - Optional credsOpt = channelRouter.resolveCredentials(channelId); - String botToken = credsOpt.map(SlackChannelRouter.SlackCredentials::botToken).orElse(""); + // Resolve bot token: prefer current resolved target, fallback to router + String botToken = null; + ResolvedTarget resolved = currentResolvedTarget.get(); + if (resolved != null) { + botToken = resolved.botToken(); + } + if (botToken == null || botToken.isEmpty()) { + var integration = channelTargetRouter.getIntegration("slack", channelId); + if (integration.isPresent() && integration.get().getPlatformConfig() != null) { + botToken = integration.get().getPlatformConfig().get("botToken"); + } + } - if (botToken.isEmpty()) { + if (botToken == null || botToken.isEmpty()) { LOGGER.warnf("No bot token configured for Slack channel %s — cannot post message", channelId); return; } @@ -502,9 +525,6 @@ private void postMessage(String channelId, String threadTs, String text) { return; } } else { - // All retries exhausted — structured log for operator recovery. - // The agent response is still in conversation memory; this log - // provides enough context to manually re-deliver if needed. LOGGER.errorf("SLACK_DELIVERY_FAILED | channel=%s | threadTs=%s | textLength=%d | attempts=%d | error=%s", channelId, threadTs, text != null ? text.length() : 0, SLACK_API_MAX_RETRIES, e.getMessage()); @@ -513,6 +533,41 @@ private void postMessage(String channelId, String threadTs, String text) { } } + /** + * Post a help message listing available targets for this channel. + */ + private void postHelp(String channelId, String threadTs) { + var integration = channelTargetRouter.getIntegration("slack", channelId); + if (integration.isEmpty()) { + postMessage(channelId, threadTs, + "👋 Hi! Send me a message and I'll respond."); + return; + } + + var config = integration.get(); + var sb = new StringBuilder(); + sb.append("👋 *Available targets in this channel:*\n\n"); + + for (ChannelTarget target : config.getTargets()) { + String type = target.getType() == ChannelTarget.TargetType.GROUP ? "group" : "agent"; + String isDefault = target.getName().equalsIgnoreCase(config.getDefaultTargetName()) + ? " _(default)_" + : ""; + sb.append("• *").append(target.getName()).append("*").append(isDefault); + sb.append(" [").append(type).append("]\n"); + if (target.getTriggers() != null && !target.getTriggers().isEmpty()) { + sb.append(" Triggers: "); + sb.append(String.join(", ", target.getTriggers().stream() + .map(t -> "`" + t + ":`") + .toList())); + sb.append("\n"); + } + } + + sb.append("\n_Type a message to talk to the default target, or use a trigger keyword._"); + postMessage(channelId, threadTs, sb.toString()); + } + /** * Get the thread timestamp for threading replies. Returns the original * message's ts for new threads, or the existing thread_ts for replies within a diff --git a/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java b/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java index e3032b709..4840239df 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java @@ -1,6 +1,6 @@ package ai.labs.eddi.integrations.slack.rest; -import ai.labs.eddi.integrations.slack.SlackChannelRouter; +import ai.labs.eddi.integrations.channels.ChannelTargetRouter; import ai.labs.eddi.integrations.slack.SlackEventHandler; import ai.labs.eddi.integrations.slack.SlackIntegrationConfig; import ai.labs.eddi.integrations.slack.SlackSignatureVerifier; @@ -27,8 +27,8 @@ * are delegated to {@link SlackEventHandler} for async processing. *
*

- * Signing secrets are resolved per-agent from {@link SlackChannelRouter}. The - * verifier tries all known secrets (supporting multi-workspace deployments). + * Signing secrets are resolved from {@link ChannelTargetRouter}. The verifier + * tries all known secrets (supporting multi-workspace deployments). *

* Critical: Slack expects HTTP 200 within 3 seconds. This endpoint responds * immediately and processes events asynchronously. @@ -45,19 +45,19 @@ public class RestSlackWebhook { }; private final SlackIntegrationConfig config; - private final SlackChannelRouter channelRouter; + private final ChannelTargetRouter channelTargetRouter; private final SlackSignatureVerifier signatureVerifier; private final SlackEventHandler eventHandler; private final ObjectMapper objectMapper; @Inject public RestSlackWebhook(SlackIntegrationConfig config, - SlackChannelRouter channelRouter, + ChannelTargetRouter channelTargetRouter, SlackSignatureVerifier signatureVerifier, SlackEventHandler eventHandler, ObjectMapper objectMapper) { this.config = config; - this.channelRouter = channelRouter; + this.channelTargetRouter = channelTargetRouter; this.signatureVerifier = signatureVerifier; this.eventHandler = eventHandler; this.objectMapper = objectMapper; @@ -88,7 +88,7 @@ public Response handleEvents(String rawBody, } // Step 1: Verify signature against all known signing secrets - Set signingSecrets = channelRouter.getAllSigningSecrets(); + Set signingSecrets = channelTargetRouter.getSigningSecrets("slack"); if (!signatureVerifier.verify(timestamp, rawBody, signature, signingSecrets)) { LOGGER.warnf("Slack signature verification failed (timestamp=%s)", timestamp); return Response.status(Response.Status.FORBIDDEN) From 51759266062eb67b38d1855045486604d036e640 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 10:24:35 +0200 Subject: [PATCH 05/35] feat(channels): add MCP admin tools + migration helper for channel integrations New MCP tools (admin-only): - list_channel_integrations: list all channel integration descriptors - read_channel_integration: read full config by ID - create_channel_integration: create with validation - update_channel_integration: update existing config - delete_channel_integration: soft or permanent delete - migrate_channel_connectors: scan legacy ChannelConnectors on deployed agents and convert to standalone ChannelIntegrationConfigurations (dry-run by default, non-destructive) --- .../labs/eddi/engine/mcp/McpAdminTools.java | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java index 3c9ce50d3..f60f13818 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java @@ -1168,4 +1168,237 @@ public String retryFailedSchedule(@ToolArg(description = "Schedule ID (required) return errorJson("Failed to retry schedule: " + e.getMessage()); } } + + // ==================== Channel Integration Tools ==================== + + @Tool(name = "list_channel_integrations", description = "List all channel integration configurations. " + + "Returns descriptors with name, channelType, and target count.") + public String listChannelIntegrations( + @ToolArg(description = "Optional filter string") String filter, + @ToolArg(description = "Maximum number of results (default 20)") Integer limit) { + requireRole(identity, authEnabled, "eddi-admin"); + try { + int limitInt = limit != null ? limit : 20; + String filterStr = filter != null ? filter : ""; + var channelStore = getRestStore( + ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); + var descriptors = channelStore.readChannelDescriptors(filterStr, 0, limitInt); + return jsonSerialization.serialize(descriptors); + } catch (Exception e) { + LOGGER.error("MCP list_channel_integrations failed", e); + return errorJson("Failed to list channel integrations: " + e.getMessage()); + } + } + + @Tool(name = "read_channel_integration", description = "Read a channel integration configuration by ID. " + + "Returns the full config with targets, triggers, platformConfig, and observe mode settings.") + public String readChannelIntegration( + @ToolArg(description = "Channel integration ID (required)") String channelId, + @ToolArg(description = "Version number (default: latest)") Integer version) { + requireRole(identity, authEnabled, "eddi-admin"); + if (channelId == null || channelId.isBlank()) + return errorJson("channelId is required"); + try { + var channelStore = getRestStore( + ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); + int ver = version != null ? version : channelStore.getCurrentVersion(channelId); + var config = channelStore.readChannel(channelId, ver); + + var result = new LinkedHashMap(); + result.put("channelId", channelId); + result.put("version", ver); + result.put("configuration", config); + return jsonSerialization.serialize(result); + } catch (Exception e) { + LOGGER.error("MCP read_channel_integration failed for " + channelId, e); + return errorJson("Failed to read channel integration: " + e.getMessage()); + } + } + + @Tool(name = "create_channel_integration", description = "Create a new channel integration configuration. " + + "Requires JSON body with name, channelType, platformConfig, targets[], and defaultTargetName. " + + "Returns the new resource ID and URI.") + public String createChannelIntegration( + @ToolArg(description = "Full JSON configuration body (required)") String config) { + requireRole(identity, authEnabled, "eddi-admin"); + if (config == null || config.isBlank()) + return errorJson("config is required"); + try { + var channelConfig = jsonSerialization.deserialize(config, + ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration.class); + var channelStore = getRestStore( + ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); + Response response = channelStore.createChannel(channelConfig); + String location = response.getHeaderString("Location"); + String newId = extractIdFromLocation(location); + + return resultJson("created", Map.of( + "channelId", newId != null ? newId : "unknown", + "name", channelConfig.getName() != null ? channelConfig.getName() : "", + "channelType", channelConfig.getChannelType() != null ? channelConfig.getChannelType() : "", + "targetCount", channelConfig.getTargets() != null ? channelConfig.getTargets().size() : 0, + "location", location != null ? location : "unknown", + "status", response.getStatus())); + } catch (Exception e) { + LOGGER.error("MCP create_channel_integration failed", e); + return errorJson("Failed to create channel integration: " + e.getMessage()); + } + } + + @Tool(name = "update_channel_integration", description = "Update an existing channel integration configuration.") + public String updateChannelIntegration( + @ToolArg(description = "Channel integration ID (required)") String channelId, + @ToolArg(description = "Current version number (required)") Integer version, + @ToolArg(description = "Full JSON configuration body (required)") String config) { + requireRole(identity, authEnabled, "eddi-admin"); + if (channelId == null || channelId.isBlank()) + return errorJson("channelId is required"); + if (config == null || config.isBlank()) + return errorJson("config is required"); + try { + int ver = version != null ? version : 1; + var channelConfig = jsonSerialization.deserialize(config, + ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration.class); + var channelStore = getRestStore( + ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); + Response response = channelStore.updateChannel(channelId, ver, channelConfig); + String location = response.getHeaderString("Location"); + int newVersion = extractVersionFromLocation(location); + + return resultJson("updated", Map.of( + "channelId", channelId, + "previousVersion", ver, + "newVersion", newVersion, + "status", response.getStatus())); + } catch (Exception e) { + LOGGER.error("MCP update_channel_integration failed for " + channelId, e); + return errorJson("Failed to update channel integration: " + e.getMessage()); + } + } + + @Tool(name = "delete_channel_integration", description = "Delete a channel integration configuration.") + public String deleteChannelIntegration( + @ToolArg(description = "Channel integration ID (required)") String channelId, + @ToolArg(description = "Current version number (required)") Integer version, + @ToolArg(description = "Permanently delete? (default: false)") Boolean permanent) { + requireRole(identity, authEnabled, "eddi-admin"); + if (channelId == null || channelId.isBlank()) + return errorJson("channelId is required"); + try { + int ver = version != null ? version : 1; + boolean isPermanent = permanent != null ? permanent : false; + var channelStore = getRestStore( + ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); + Response response = channelStore.deleteChannel(channelId, ver, isPermanent); + + return resultJson("deleted", Map.of( + "channelId", channelId, + "version", ver, + "permanent", isPermanent, + "status", response.getStatus())); + } catch (Exception e) { + LOGGER.error("MCP delete_channel_integration failed for " + channelId, e); + return errorJson("Failed to delete channel integration: " + e.getMessage()); + } + } + + @Tool(name = "migrate_channel_connectors", description = "Migrate legacy ChannelConnector entries from agent configs " + + "to standalone ChannelIntegrationConfigurations. Scans all deployed agents and creates one " + + "ChannelIntegrationConfiguration per unique channelId. Non-destructive (does not modify agent configs). " + + "Run this once to upgrade from the old channel model.") + public String migrateChannelConnectors( + @ToolArg(description = "Dry run mode — show what would be created without creating (default: true)") Boolean dryRun) { + requireRole(identity, authEnabled, "eddi-admin"); + try { + boolean isDryRun = dryRun == null || dryRun; + var localAgentStore = getRestStore(IRestAgentStore.class); + var channelStore = getRestStore( + ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); + + var statuses = agentAdmin.getDeploymentStatuses( + ai.labs.eddi.engine.model.Deployment.Environment.production); + var migrated = new ArrayList>(); + var skipped = new ArrayList>(); + + for (var status : statuses) { + if (status.getDescriptor() == null || status.getDescriptor().isDeleted()) + continue; + String agentId = status.getAgentId(); + try { + AgentConfiguration agentConfig = localAgentStore.readAgent( + agentId, status.getAgentVersion()); + if (agentConfig == null || agentConfig.getChannels() == null) + continue; + + for (var connector : agentConfig.getChannels()) { + if (connector.getType() == null || connector.getConfig() == null) + continue; + String channelType = connector.getType().toString().toLowerCase(); + String platformChannelId = connector.getConfig().get("channelId"); + if (platformChannelId == null || platformChannelId.isBlank()) + continue; + + // Build a ChannelIntegrationConfiguration + var newConfig = new ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration(); + String agentName = status.getDescriptor().getName(); + newConfig.setName(channelType + " — " + + (agentName != null ? agentName : agentId)); + newConfig.setChannelType(channelType); + newConfig.setPlatformConfig(new java.util.HashMap<>(connector.getConfig())); + + // Create a default target pointing to the agent + var target = new ai.labs.eddi.configs.channels.model.ChannelTarget(); + target.setName("default"); + target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.AGENT); + target.setTargetId(agentId); + // If groupId present, make it a group target + String groupId = connector.getConfig().get("groupId"); + if (groupId != null && !groupId.isBlank()) { + target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.GROUP); + target.setTargetId(groupId); + } + newConfig.setTargets(List.of(target)); + newConfig.setDefaultTargetName("default"); + + var entry = new LinkedHashMap(); + entry.put("agentId", agentId); + entry.put("channelType", channelType); + entry.put("platformChannelId", platformChannelId); + + if (isDryRun) { + entry.put("action", "would_create"); + entry.put("config", newConfig); + migrated.add(entry); + } else { + try { + Response response = channelStore.createChannel(newConfig); + String location = response.getHeaderString("Location"); + entry.put("action", "created"); + entry.put("location", location); + migrated.add(entry); + } catch (Exception createErr) { + entry.put("action", "failed"); + entry.put("error", createErr.getMessage()); + skipped.add(entry); + } + } + } + } catch (Exception e) { + skipped.add(Map.of("agentId", agentId, "error", e.getMessage())); + } + } + + var result = new LinkedHashMap(); + result.put("dryRun", isDryRun); + result.put("migratedCount", migrated.size()); + result.put("skippedCount", skipped.size()); + result.put("migrated", migrated); + if (!skipped.isEmpty()) + result.put("skipped", skipped); + return resultJson("migration_complete", result); + } catch (Exception e) { + LOGGER.error("MCP migrate_channel_connectors failed", e); + return errorJson("Failed to migrate channel connectors: " + e.getMessage()); + } + } } From ed684dba56d52ab2c857b99c7679b4545e7e4061 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 10:26:00 +0200 Subject: [PATCH 06/35] docs: update changelog and task list for channel integration work --- docs/changelog.md | 61 +++++++ planning/conversation-cancel-plan.md | 263 +++++++++++++++++---------- 2 files changed, 224 insertions(+), 100 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 07c57183b..8546ae9d5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,6 +13,67 @@ Each entry follows this format: - **Decision** — Key design decisions and their reasoning - **Files** — Links to modified files +## Channel Integration Refactor — Decoupled Multi-Target Architecture (2026-04-18) + +**Repo:** EDDI (`feature/channel-integrations`) + +**What changed:** Refactored the Slack integration from a tightly-coupled, agent-embedded model (`ChannelConnector` inside `AgentConfiguration`) to a standalone, multi-target, multi-platform architecture. + +### 1. Standalone Config Resource + +Created `ChannelIntegrationConfiguration` — a first-class versioned MongoDB document (`eddi://ai.labs.channel/channelstore/channels/{id}`) decoupled from agents. Each config holds: +- `channelType` (slack, teams, discord) +- `platformConfig` (credentials via vault references) +- `targets[]` — each with name, type (AGENT/GROUP), targetId, and trigger keywords +- `defaultTargetName` — fallback when no trigger matches +- `observeMode` / `ObserveConfig` — schema reserved for future passive observation + +### 2. ChannelTargetRouter + +Platform-agnostic router replacing `SlackChannelRouter`: +- **Colon-required triggers**: `architect: question` routes to the "architect" target +- **Thread target locking**: First message locks the target for the thread (prevents mid-thread switching) +- **New-style wins**: If a `ChannelIntegrationConfiguration` covers a channelId, all legacy `ChannelConnector` entries for that channel are ignored +- **Signing secret aggregation**: Collects from both new and legacy configs for webhook verification + +### 3. Slack Adapter Refactor + +- `SlackEventHandler` → uses `ChannelTargetRouter` for all routing decisions +- Removed `group:` magic prefix — groups now reached via configured triggers +- Added `postHelp()` — lists available targets with trigger keywords when message is blank or "help" +- `postMessage()` resolves bot token from `ResolvedTarget` or router fallback +- `RestSlackWebhook` → uses `ChannelTargetRouter.getSigningSecrets("slack")` + +### 4. MCP Admin Tools + Migration + +Added 6 new MCP tools (admin-only): +- `list_channel_integrations`, `read_channel_integration`, `create_channel_integration` +- `update_channel_integration`, `delete_channel_integration` +- `migrate_channel_connectors` — scans legacy `ChannelConnector` entries on deployed agents and converts to standalone `ChannelIntegrationConfiguration` (dry-run by default, non-destructive) + +### Design Decisions + +- **Colon-required syntax over fuzzy matching**: Deterministic, no ambiguity. `architect: hello` matches; `architect hello` does not. +- **Thread locking over repeated resolution**: Prevents jarring mid-thread target switches in multi-target channels. +- **Schema-now for observe mode**: `observeMode` and `ObserveConfig` are in the model but not wired. Avoids future MongoDB migration when observation is implemented. +- **Migration as MCP tool (not REST endpoint)**: Fits admin tooling pattern, supports dry-run, accessible from Claude/MCP clients. + +**Files:** +- `ChannelIntegrationConfiguration.java`, `ChannelTarget.java`, `ObserveConfig.java` — [NEW] models +- `IChannelIntegrationStore.java` — [NEW] store interface +- `IRestChannelIntegrationStore.java` — [NEW] REST interface +- `ChannelIntegrationStore.java` — [NEW] DB-agnostic store +- `RestChannelIntegrationStore.java` — [NEW] REST implementation with validation +- `ChannelTargetRouter.java` — [NEW] platform-agnostic router +- `ChannelTargetRouterTest.java` — [NEW] 23 unit tests +- `SlackEventHandler.java` — refactored to use ChannelTargetRouter +- `RestSlackWebhook.java` — updated credential resolution +- `McpAdminTools.java` — 6 new channel integration tools + +**In Progress:** Manager UI, file attachment forwarding, observe mode (future PRs). + +--- + ## PR Review Fixes — Quota Ordering, Log Injection, Doc Hygiene (2026-04-17) **Repo:** EDDI (`feature/observability`) diff --git a/planning/conversation-cancel-plan.md b/planning/conversation-cancel-plan.md index 39591ef05..60fa6dc8c 100644 --- a/planning/conversation-cancel-plan.md +++ b/planning/conversation-cancel-plan.md @@ -1,7 +1,7 @@ # Conversation Cancellation & Lifecycle Control > **Scope**: Cancel/stop for group discussions and regular conversations. -> **HITL prerequisite**: This plan explicitly designs the control mechanism to be extensible for HITL pause/resume/approve without refactoring. +> **HITL prerequisite**: This plan designs the detection mechanism (safe-point checking) to be reusable for HITL. However, HITL pause/resume requires significant additional work beyond what cancel provides — this is documented honestly in §6. --- @@ -27,7 +27,7 @@ Currently, neither group discussions nor regular agent conversations can be stop ## 2. Design: `DiscussionControlToken` -A shared, thread-safe control object that the execution loop checks at **safe points**. Designed from the start to support HITL operations beyond simple cancel. +A shared, thread-safe control object that the execution loop checks at **safe points**. ### 2.1 Control Signal Enum @@ -39,22 +39,20 @@ public enum ControlSignal { /** Graceful stop — finish current speaker's turn, then stop before the next speaker */ CANCEL_GRACEFUL, - /** Immediate stop — attempt to interrupt the current LLM call */ + /** Immediate stop — best-effort attempt to interrupt the current in-flight call (see §3.4) */ CANCEL_IMMEDIATE, - - // --- HITL extensions (Phase 9b, not implemented now) --- - // PAUSE, // Stop after current speaker, mark as PAUSED (resumable) - // AWAIT_APPROVAL, // Block until human approves/rejects the last response } ``` +> **Why not include HITL signals here?** See §6 — PAUSE requires fundamentally different handling (state serialization + re-entry) that would complicate the cancel-only implementation. Better to add HITL signals when that feature is built. + ### 2.2 Token Class ```java public class DiscussionControlToken { private final AtomicReference signal = new AtomicReference<>(ControlSignal.CONTINUE); - /** Set the control signal. Thread-safe. */ + /** Set the control signal. Thread-safe, idempotent. */ public void setSignal(ControlSignal signal) { this.signal.set(signal); } @@ -69,10 +67,6 @@ public class DiscussionControlToken { var s = signal.get(); return s == ControlSignal.CANCEL_GRACEFUL || s == ControlSignal.CANCEL_IMMEDIATE; } - - // --- HITL extension point (Phase 9b) --- - // public boolean isPaused() { return signal.get() == ControlSignal.PAUSE; } - // public CompletableFuture awaitApproval() { ... } } ``` @@ -86,40 +80,93 @@ private final ConcurrentMap activeTokens = new C - Token is created when `startAndDiscussAsync()` starts, keyed by `conversationId` - Token is removed in the `finally` block of `executeDiscussion()` - `cancelDiscussion(convId, mode)` looks up the token and sets the signal +- If token not found (conversation already finished), return gracefully --- ## 3. Safe Points -### 3.1 Group Discussions +### 3.1 Group Discussions — Graceful Cancel -The execution loop has clear phase/speaker boundaries. Safe points are: +The execution loop has clear phase/speaker boundaries: ``` executeDiscussion() - for each phase: ← ✅ Safe point: between phases + for each phase: ← ✅ CHECK: between phases for each repeat: - for each speaker (sequential): ← ✅ Safe point: between speakers (GRACEFUL) - executeSpeakerTurn() ← ⚠️ IMMEDIATE: needs to interrupt the blocking call + for each speaker (sequential): ← ✅ CHECK: before each speaker turn + executeAgentTurn() for each speaker (parallel): - futures.get(timeout) ← ✅ Safe point: cancel remaining futures + futures.get(timeout) ← ✅ CHECK: after each resolved future — cancel remaining +``` + +**Check locations** (3 insertion points): + +1. **Top of phase loop** (line ~208): `if (token.isCancelled()) break;` +2. **Top of speaker loop** in `executeSequentialPhase()` (line ~385): `if (token.isCancelled()) break;` +3. **After each future** in `executeParallelPhase()` (line ~440): cancel remaining futures if signal received + +When the check triggers: +- Set `gc.setState(CANCELLED)` +- Persist the conversation with partial transcript +- Emit `onGroupError(new GroupErrorEvent("Discussion cancelled by user"))` +- Return from `executeDiscussion()` + +### 3.2 Group Discussions — Immediate Cancel + +Adds a 4th check: + +4. **During `executeAgentTurn()`**: The blocking `responseFuture.get(timeout)` can be interrupted. Store the future on the token so `cancelDiscussion()` can call `future.cancel(true)`. + +```java +// In DiscussionControlToken — for IMMEDIATE only: +private volatile CompletableFuture activeFuture; + +public void setActiveFuture(CompletableFuture f) { this.activeFuture = f; } + +public void cancelActiveFuture() { + var f = activeFuture; + if (f != null) f.cancel(true); +} +``` + +> **⚠️ Limitation**: `cancel(true)` sets the interrupt flag on the thread. Whether the underlying LLM HTTP call actually responds to interruption depends on the HTTP client implementation. Java's `HttpClient` does respect interruption, but if the call goes through `ConversationService.say()` → `ConversationCoordinator.submitInOrder()` → `BaseRuntime`, the interrupt may not reach the actual HTTP call. **IMMEDIATE is best-effort** — it will reliably prevent the next speaker from starting, but may not abort the current LLM call mid-stream. + +### 3.3 Cascading: Group → Nested Sub-Group + +When the parent group checks `token.isCancelled()` and breaks, any currently-executing child `discuss()` call is already running in the same virtual thread. The simplest cascade: + +- **Pass the token to child discussions**: `executeDiscussion(gc, config, phases, question, listener, token)` — child checks the same token at its own safe points. +- This works because `executeGroupMemberTurn()` calls `discuss()` synchronously (not async) — so the parent's token is visible to the child. + +### 3.4 Cascading: Group → In-Flight Agent Turn + +This is the hardest part. When `executeAgentTurn()` calls `conversationService.say()`: + +```java +conversationService.say(DEFAULT_ENV, member.agentId(), convId, false, true, null, inputData, false, snapshot -> { + String response = extractResponse(snapshot); + responseFuture.complete(response); +}); ``` -**Graceful cancel check locations** (checked at each `✅`): +The `say()` call submits work to `ConversationCoordinator.submitInOrder()` which dispatches to `BaseRuntime`. The group service does NOT have a reference to the `IConversationMemory` being used inside. + +**For GRACEFUL cancel**: No cascading needed — the agent finishes its turn, and the cancel check fires at the next safe point (before the next speaker). + +**For IMMEDIATE cancel**: Two options: -1. **Top of phase loop** (line ~208) — before `executeSequentialPhase()` / `executeParallelPhase()` -2. **Top of speaker loop** in `executeSequentialPhase()` (line ~385) — before each `executeAgentTurn()` -3. **After each future** in `executeParallelPhase()` (line ~440) — cancel remaining futures if signal received +a) **Cancel the future** (`responseFuture.cancel(true)`): The group service thread unblocks with `CancellationException`. But the agent's lifecycle continues running in the coordinator's thread — it just has no listener waiting for the result. The result is discarded. _Wasted compute but functional._ -**Immediate cancel** — additionally: +b) **Track active memories in ConversationService** (more complex): Add a `ConcurrentMap` to `ConversationService` so external callers can set a cancel flag. This requires modifying `ConversationService.say()` to register/unregister the memory around execution. -4. **In `executeAgentTurn()`** (line ~565) — the `CompletableFuture responseFuture` has a blocking `.get(timeout)`. Interrupt the thread to abort the LLM HTTP call. The `cancel(true)` mechanism already exists for parallel timeout. +**Recommendation**: Start with option (a) for IMMEDIATE. It's pragmatic — the worst case is one wasted LLM call, but the discussion stops immediately from the group's perspective. Option (b) can be added later if the wasted compute becomes a real concern, and it's needed anyway for Phase B (regular conversation cancel). -### 3.2 Regular Conversations +### 3.5 Regular Conversations -The lifecycle pipeline runs tasks sequentially via `ILifecycleManager.executeLifecycle()`. The existing `ConversationStopException` already provides the interrupt mechanism — we just need to trigger it externally. +The lifecycle pipeline runs tasks sequentially via `ILifecycleManager.executeLifecycle()`. The existing `ConversationStopException` provides the interruption mechanism. -**Approach**: Add a `volatile boolean cancelled` flag to `IConversationMemory`. The `LifecycleManager` checks this **between lifecycle tasks** (between parser, rules, LLM, output steps). If cancelled, throw `ConversationStopException`. +**Approach**: Add a `volatile boolean cancelled` flag to `IConversationMemory`. The `LifecycleManager` checks **between lifecycle tasks** (between parser, rules, LLM, output). If cancelled, throw `ConversationStopException`. ``` executeLifecycle() @@ -128,21 +175,19 @@ executeLifecycle() task.executeTask(memory); ``` -This reuses the existing `ConversationStopException` handling in `Conversation.executeConversationStep()` (line ~295) which already catches this exception and completes gracefully. +**Prerequisite**: `ConversationService` must track active `IConversationMemory` instances so external callers can find and cancel them: -### 3.3 Cascading: Group → Agent → Sub-Group +```java +// In ConversationService +private final ConcurrentMap activeMemories = new ConcurrentHashMap<>(); -When a group is cancelled: +public void cancelConversation(String conversationId) { + var memory = activeMemories.get(conversationId); + if (memory != null) memory.setCancelled(true); +} +``` -1. The `DiscussionControlToken` signal is set -2. The phase loop breaks at the next safe point -3. **Currently-executing agent turns** need cascading: - - `executeAgentTurn()` calls `conversationService.say()` which runs the lifecycle - - The agent's `ConversationMemory` needs a cancel flag too - - The group service sets `memory.setCancelled(true)` before checking the token -4. **Nested sub-groups** (`executeGroupMemberTurn()` calls `discuss()` recursively): - - Pass the parent's `DiscussionControlToken` to child discussions - - Child discussions inherit the cancel signal automatically +This reuses the existing `ConversationStopException` handling in `Conversation.executeConversationStep()` (line ~295). --- @@ -159,10 +204,13 @@ Content-Type: application/json } ``` -- **`GRACEFUL`** (default): Finish the current speaker's turn, then stop. Transcript is consistent. -- **`IMMEDIATE`**: Interrupt the current LLM call. The speaking agent's turn gets a SKIPPED entry. +- **`GRACEFUL`** (default if body omitted): Finish the current speaker's turn, then stop. Transcript is consistent and complete up to the cancellation point. +- **`IMMEDIATE`**: Best-effort interrupt of the current LLM call (see §3.4 limitations). The speaking agent's turn may get a SKIPPED entry or may complete if the interrupt doesn't reach the HTTP call. -**Response**: `200 OK` if found and cancelled, `404` if conversation not found or already completed. +**Responses**: +- `200 OK` + partial `GroupConversation` body — if actively running and cancelled +- `200 OK` + existing `GroupConversation` — if already completed/failed (idempotent, no error) +- `404 Not Found` — conversation ID doesn't exist **New state**: `GroupConversationState.CANCELLED` @@ -172,26 +220,28 @@ Content-Type: application/json POST /agents/{agentId}/conversations/{conversationId}/cancel ``` -No body needed — regular conversations have a single in-flight turn, so there's no graceful/immediate distinction. The lifecycle is interrupted between tasks via `ConversationStopException`. +No body needed — regular conversations have a single in-flight turn, so the distinction is always "interrupt between lifecycle tasks." -**Existing state**: `ConversationState.EXECUTION_INTERRUPTED` (already exists!) +**Existing state**: `ConversationState.EXECUTION_INTERRUPTED` (already exists) -### 4.3 SSE Auto-Cancel (Group only) +### 4.3 SSE Auto-Cancel -When the SSE client disconnects (`eventSink.isClosed()`), the REST layer automatically calls `cancelDiscussion(convId, GRACEFUL)`. This is the "user closed the browser tab" scenario. +When the SSE client disconnects, the REST layer should auto-cancel the discussion to prevent wasted compute. The most reliable detection point is in the listener callbacks — checked at every event emission: ```java -// In RestGroupConversation.discussStreaming(), modify the listener: -@Override -public void onSpeakerStart(SpeakerStartEvent event) { +// In RestGroupConversation — wrap every sendEvent call: +private boolean sendOrCancel(SseEventSink eventSink, Sse sse, String eventName, String data, String convId) { if (eventSink.isClosed()) { - groupConversationService.cancelDiscussion(gc.getId(), ControlSignal.CANCEL_GRACEFUL); - return; + groupConversationService.cancelDiscussion(convId, ControlSignal.CANCEL_GRACEFUL); + return true; // cancelled } - sendEvent(eventSink, sse, EVENT_SPEAKER_START, toJson(event)); + sendEvent(eventSink, sse, eventName, data); + return false; } ``` +> **Limitation**: This only detects disconnect when the next SSE event fires. If one agent takes 60 seconds to respond, the disconnect isn't detected until that agent completes. This is acceptable — it's a safety net, not the primary cancel mechanism. + --- ## 5. State Transitions @@ -200,112 +250,125 @@ public void onSpeakerStart(SpeakerStartEvent event) { ``` CREATED → IN_PROGRESS → SYNTHESIZING → COMPLETED - ↘ CANCELLED (via cancel) + ↘ CANCELLED (via user cancel or SSE disconnect) ↘ FAILED (via error) - // HITL extensions (Phase 9b): - // → PAUSED → IN_PROGRESS (resume) - // → AWAITING_APPROVAL → IN_PROGRESS (approved) | CANCELLED (rejected) ``` ### Regular Conversation ``` READY → IN_PROGRESS → READY (completed turn) - ↘ EXECUTION_INTERRUPTED (via cancel — already exists) + ↘ EXECUTION_INTERRUPTED (via cancel — state already exists) ↘ ERROR ``` --- -## 6. HITL Extension Points +## 6. HITL Relationship — Honest Assessment + +### What cancel shares with HITL + +The **detection mechanism** is identical: checking a control signal at safe points in the execution loop. Both cancel and HITL need: +- Thread-safe signal propagation +- Defined safe points between speakers/phases/lifecycle tasks +- State persistence of partial results + +### What HITL needs beyond cancel + +HITL PAUSE is **not** "cancel but save state." It requires fundamentally different handling: -The `DiscussionControlToken` is explicitly designed so HITL can be added **without modifying** the cancel implementation: +| Concern | Cancel | HITL Pause | +|---------|--------|------------| +| Thread lifecycle | Break loop, return, GC thread | Must release thread — can't hold a virtual thread blocked for hours | +| State | Save partial transcript with CANCELLED | Save full execution context (phase index, speaker index, transcript, repeat count) so loop can resume | +| Re-entry | None | Resume `executeDiscussion()` from saved checkpoint — need to reconstruct the loop mid-iteration | +| Token lifetime | Removed after execution ends | Kept alive across pause/resume — potentially hours | +| Concurrent safety | Simple: signal is set, loop exits | Complex: resume may race with a second cancel or another pause | -| Cancel (this plan) | HITL (Phase 9b) | -|---|---| -| `CANCEL_GRACEFUL` signal | `PAUSE` signal — same check location, but saves state as PAUSED instead of CANCELLED | -| Token removed after discussion ends | Token kept alive for PAUSED conversations — allows resume | -| No human interaction needed | `AWAIT_APPROVAL` signal — blocks the execution thread until a human endpoint is called | -| `POST .../cancel` | `POST .../pause`, `POST .../resume`, `POST .../approve`, `POST .../reject` | +### What cancel provides as HITL groundwork -**Key architectural guarantee**: The safe-point checking logic in the phase loop and lifecycle manager is **identical** for cancel and HITL. Adding HITL means: -1. Add new `ControlSignal` variants (`PAUSE`, `AWAIT_APPROVAL`) -2. Add corresponding state enum values (`PAUSED`, `AWAITING_APPROVAL`) -3. Add REST endpoints for human actions -4. In the safe-point check, handle new signals alongside the cancel ones +1. ✅ **Safe-point locations identified and proven** — these exact locations are where HITL checks go +2. ✅ **`DiscussionControlToken` pattern** — HITL extends this with `PAUSE` signal +3. ✅ **`CANCELLED` state in persistence** — `PAUSED` follows the same pattern +4. ✅ **Partial transcript persistence** — cancel proves that saving mid-discussion works +5. ✅ **SSE event model** — cancel events prove the client can handle non-COMPLETED endings -No refactoring of the execution loop is needed. +### What HITL still requires (Phase 9b) -### HITL for Regular Conversations +1. ❌ **Execution checkpoint serialization**: The phase loop's iteration state (current phase, current speaker, current repeat) must be persistable +2. ❌ **Resume from checkpoint**: `executeDiscussion()` needs a code path that starts from a saved state instead of phase 0 +3. ❌ **Human approval webhook/polling**: REST endpoints + possibly WebSocket for real-time human interaction +4. ❌ **Timeout for human response**: What happens if the human never responds? Auto-cancel? Auto-approve? +5. ❌ **Thread management**: Can't hold threads for HITL; need event-driven resume -The `volatile boolean cancelled` on `IConversationMemory` would evolve to a `ControlSignal` field (or a dedicated `ConversationControlToken`). The lifecycle manager checks between tasks. For HITL, instead of throwing `ConversationStopException`, it would block on a `CompletableFuture` that resolves when the human approves/rejects. +**Bottom line**: Cancel provides ~30% of the HITL infrastructure (detection, safe points, partial-state persistence). The remaining ~70% (checkpoint serialization, resume from checkpoint, human interaction model, thread management) is new work for Phase 9b. This is honest and by design — we build the foundation now, not a half-baked HITL. --- ## 7. Implementation Plan -### Phase A: Backend Core (EDDI repo) +### Phase A: Group Discussion Cancel (EDDI repo) — Core feature | # | File | Change | |---|------|--------| -| A1 | `DiscussionControlToken.java` [NEW] | Token class with `AtomicReference` | +| A1 | `DiscussionControlToken.java` [NEW] | Token class with `AtomicReference` + optional `activeFuture` for IMMEDIATE | | A2 | `ControlSignal.java` [NEW] | Enum: `CONTINUE`, `CANCEL_GRACEFUL`, `CANCEL_IMMEDIATE` | | A3 | `GroupConversation.java` | Add `CANCELLED` to `GroupConversationState` enum | -| A4 | `GroupConversationService.java` | Add `activeTokens` map, create/remove token around execution, check signal at safe points | +| A4 | `GroupConversationService.java` | Add `activeTokens` map; create/remove token in `executeDiscussion()`; check at 3 safe points; pass token to nested `discuss()` calls | | A5 | `IGroupConversationService.java` | Add `cancelDiscussion(String convId, ControlSignal mode)` | -| A6 | `IRestGroupConversation.java` | Add `POST /{convId}/cancel` endpoint | -| A7 | `RestGroupConversation.java` | Implement cancel endpoint + SSE auto-cancel on `eventSink.isClosed()` | +| A6 | `IRestGroupConversation.java` | Add `POST /{convId}/cancel` endpoint definition | +| A7 | `RestGroupConversation.java` | Implement cancel endpoint + SSE auto-cancel via `sendOrCancel()` wrapper | -### Phase B: Regular Conversation Cancel (EDDI repo) +### Phase B: Regular Conversation Cancel (EDDI repo) — Can be independent | # | File | Change | |---|------|--------| | B1 | `IConversationMemory.java` | Add `setCancelled(boolean)` / `isCancelled()` | | B2 | `ConversationMemory.java` | Implement with `volatile boolean` | | B3 | `LifecycleManager.java` (impl) | Check `memory.isCancelled()` between task executions, throw `ConversationStopException` | -| B4 | `IConversationService.java` | Add `cancelConversation(String convId)` | -| B5 | `ConversationService.java` | Implement: find active conversation memory, set cancelled | -| B6 | REST endpoint | Add `POST /agents/{agentId}/conversations/{convId}/cancel` | +| B4 | `ConversationService.java` | Add `activeMemories` tracking map + `cancelConversation(String convId)` | +| B5 | REST endpoint | Add `POST /agents/{agentId}/conversations/{convId}/cancel` | ### Phase C: Frontend (EDDI-Manager repo) | # | File | Change | |---|------|--------| | C1 | `groups.ts` | Add `cancelGroupDiscussion(groupId, convId, mode)` API function | -| C2 | `use-group-discussion-stream.ts` | Wire `abortStream()` to call the cancel API before disconnecting SSE | -| C3 | `group-detail.tsx` | Add Stop/Cancel button (re-introduce, now backed by real backend cancel) | -| C4 | Chat components | Add stop button to regular conversation chat during streaming | +| C2 | `use-group-discussion-stream.ts` | Wire `abortStream()` to call cancel API before disconnecting SSE | +| C3 | `group-detail.tsx` | Re-introduce Stop button — now backed by real backend cancel | +| C4 | Chat components (Phase B) | Add stop button to regular conversation chat during streaming | ### Phase D: Tests | # | Test | |---|------| -| D1 | Unit test: `DiscussionControlToken` thread safety | -| D2 | Unit test: `GroupConversationService` cancel at phase boundary | -| D3 | Unit test: `GroupConversationService` cancel cascading to sub-group | -| D4 | Integration test: SSE auto-cancel on client disconnect | -| D5 | Unit test: Regular conversation cancel between lifecycle tasks | +| D1 | Unit: `DiscussionControlToken` signal concurrency | +| D2 | Unit: `GroupConversationService` — graceful cancel at phase boundary | +| D3 | Unit: `GroupConversationService` — cancel cascading to nested sub-group | +| D4 | Integration: SSE auto-cancel on client disconnect | +| D5 | Unit: Regular conversation cancel between lifecycle tasks (Phase B) | +| D6 | Unit: Idempotent cancel on already-completed conversation | --- ## 8. Open Questions -1. **Cancel response body**: Should `POST .../cancel` return the partial `GroupConversation` (with transcript so far) or just `200 OK`? +1. **Cancel response body**: Should `POST .../cancel` return the partial `GroupConversation` or just `200 OK`? - *Recommendation*: Return the saved `GroupConversation` — the client can display the partial transcript. -2. **Idempotency**: What if cancel is called on an already-completed conversation? - - *Recommendation*: Return `200 OK` with the existing conversation — no error. +2. **Metrics**: Should cancelled discussions count as failures? + - *Recommendation*: Separate counter `eddi_group_discussion_cancelled_count`. -3. **Metrics**: Should cancelled discussions count as failures? - - *Recommendation*: New counter `eddi_group_discussion_cancelled_count` (separate from failure). +3. **Nested group cancel persistence**: When a parent is cancelled, should the child sub-group also be persisted as `CANCELLED`? + - *Recommendation*: Yes — the shared token ensures consistency. -4. **Nested group cancel**: When a parent group is cancelled, should the child sub-group's conversation also be persisted with `CANCELLED` state? - - *Recommendation*: Yes — the parent's token cascades to the child, so the child also gets `CANCELLED`. +4. **SSE disconnect = auto-cancel?** Should disconnecting the browser tab always trigger GRACEFUL cancel? Or should the discussion continue (current behavior)? + - *Recommendation*: Auto-cancel. If no client is listening, continuing wastes tokens. If the user navigates back, they can start a new discussion. --- ## 9. Relationship to Other Plans -- **Agentic Improvements Phase 9b (HITL)**: This plan provides the prerequisite infrastructure. HITL adds `PAUSE`/`AWAIT_APPROVAL` signals to the same token mechanism. -- **Multi-tenancy**: Cancel endpoints inherit the same auth/tenant context as other conversation endpoints — no special handling needed. -- **Observability**: New metrics (`cancelled_count`) and trace spans for cancel events. +- **Agentic Improvements Phase 9b (HITL)**: Cancel provides safe-point infrastructure + partial-state persistence as groundwork. HITL adds checkpoint serialization, resume-from-checkpoint, and human interaction model. ~30% reuse, ~70% new work. +- **Multi-tenancy**: Cancel endpoints inherit auth/tenant context — no special handling. +- **Observability**: New metric `eddi_group_discussion_cancelled_count` + span for cancel events. From cebdcde85b986a32ed461694741d274b67ed3197 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 11:32:10 +0200 Subject: [PATCH 07/35] fix(channels): fix 4 bugs found in code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BUG 1: Fix compilation error — 4-arg getOrCreateConversation call in tryHandleAgentFollowUp; compose intent key from channelId + parentTs - BUG 2: Fix null strippedMessage in thread replies — resolveThreadTarget returned null strippedMessage which was passed to sendAndWait; add originalText fallback in handleAgentConversation and handleGroupDiscussion - BUG 3: Fix legacy group connectors misrouted as AGENT — LegacyTarget.toChannelTarget() now checks groupId and sets GROUP type - BUG 4: Fix unbounded memory leak in threadTargetLock — replaced ConcurrentHashMap with ICache (24h TTL) via ICacheFactory injection - Minor: null-safety in postHelp, demote refresh log to debug, update test mock to use proper MapCache + Mockito pattern --- .../channels/ChannelTargetRouter.java | 27 +++++++-- .../integrations/slack/SlackEventHandler.java | 24 +++++--- .../channels/ChannelTargetRouterTest.java | 60 ++++++++++++++++++- 3 files changed, 93 insertions(+), 18 deletions(-) diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index ba2795379..d1b1c9c29 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -9,6 +9,8 @@ import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; import ai.labs.eddi.datastore.IResourceStore; import ai.labs.eddi.engine.api.IRestAgentAdministration; +import ai.labs.eddi.engine.caching.ICache; +import ai.labs.eddi.engine.caching.ICacheFactory; import ai.labs.eddi.engine.model.AgentDeploymentStatus; import ai.labs.eddi.engine.model.Deployment; import ai.labs.eddi.secrets.SecretResolver; @@ -19,6 +21,7 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import java.time.Duration; import static ai.labs.eddi.utils.RestUtilities.extractResourceId; @@ -50,6 +53,8 @@ public class ChannelTargetRouter { private final IRestAgentStore agentStore; private final SecretResolver secretResolver; + private final ICacheFactory cacheFactory; + // ─── Cached state (atomic reference swap) ────────────────────────────────── /** @@ -69,20 +74,25 @@ public class ChannelTargetRouter { private volatile long lastRefreshTime = 0; private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); - /** Thread → locked target (prevents mid-thread target switching). */ - private final Map threadTargetLock = new ConcurrentHashMap<>(); + /** + * Thread → locked target (prevents mid-thread target switching). TTL-evicted. + */ + private final ICache threadTargetLock; @Inject public ChannelTargetRouter(IChannelIntegrationStore channelStore, IDocumentDescriptorStore descriptorStore, IRestAgentAdministration agentAdmin, IRestAgentStore agentStore, - SecretResolver secretResolver) { + SecretResolver secretResolver, + ICacheFactory cacheFactory) { this.channelStore = channelStore; this.descriptorStore = descriptorStore; this.agentAdmin = agentAdmin; this.agentStore = agentStore; this.secretResolver = secretResolver; + this.cacheFactory = cacheFactory; + this.threadTargetLock = cacheFactory.getCache("channel-thread-locks", Duration.ofHours(24)); } // ─── Public API ──────────────────────────────────────────────────────────── @@ -370,7 +380,7 @@ private void refreshInternal() { slackSigningSecrets = Set.copyOf(newSigningSecrets); newStyleChannelIds = Set.copyOf(coveredChannelIds); - LOGGER.infof("Channel target router refreshed: %d integrations, %d legacy, %d signing secrets", + LOGGER.debugf("Channel target router refreshed: %d integrations, %d legacy, %d signing secrets", newIntegrationMap.size(), newLegacyMap.size(), newSigningSecrets.size()); } @@ -429,8 +439,13 @@ record LegacyTarget(String agentId, String botToken, String signingSecret, Strin ChannelTarget toChannelTarget() { var target = new ChannelTarget(); target.setName("default"); - target.setType(ChannelTarget.TargetType.AGENT); - target.setTargetId(agentId); + if (groupId != null) { + target.setType(ChannelTarget.TargetType.GROUP); + target.setTargetId(groupId); + } else { + target.setType(ChannelTarget.TargetType.AGENT); + target.setTargetId(agentId); + } return target; } } diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java index 69508f10a..f841895b2 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -219,8 +219,8 @@ && tryHandleAgentFollowUp(parentTs, channelId, userId, text, threadTs)) { currentResolvedTarget.set(resolved); try { switch (resolved.target().getType()) { - case AGENT -> handleAgentConversation(resolved, channelId, userId, threadTs); - case GROUP -> handleGroupDiscussion(resolved, channelId, userId, threadTs); + case AGENT -> handleAgentConversation(resolved, channelId, userId, threadTs, text); + case GROUP -> handleGroupDiscussion(resolved, channelId, userId, threadTs, text); } } finally { currentResolvedTarget.remove(); @@ -231,7 +231,7 @@ && tryHandleAgentFollowUp(parentTs, channelId, userId, text, threadTs)) { * Handle a standard 1:1 agent conversation routed via ChannelTargetRouter. */ private void handleAgentConversation(ResolvedTarget resolved, String channelId, - String userId, String threadTs) + String userId, String threadTs, String originalText) throws Exception { String agentId = resolved.target().getTargetId(); String targetName = resolved.target().getName(); @@ -243,8 +243,12 @@ private void handleAgentConversation(ResolvedTarget resolved, String channelId, : "legacy"; String intent = "channel:" + integrationId + ":" + targetName + ":" + threadKey; + // Use strippedMessage (trigger keyword removed) or fall back to original text + // (thread replies from resolveThreadTarget have strippedMessage=null) + String message = resolved.strippedMessage() != null ? resolved.strippedMessage() : originalText; + String conversationId = getOrCreateConversation(agentId, userId, intent); - String response = sendAndWait(conversationId, resolved.strippedMessage()); + String response = sendAndWait(conversationId, message); postMessageChunked(channelId, threadTs, response); } @@ -254,7 +258,7 @@ private void handleAgentConversation(ResolvedTarget resolved, String channelId, * Handle a group discussion trigger routed via ChannelTargetRouter. */ private void handleGroupDiscussion(ResolvedTarget resolved, String channelId, - String userId, String threadTs) { + String userId, String threadTs, String originalText) { String groupId = resolved.target().getTargetId(); String botToken = resolved.botToken(); @@ -268,7 +272,7 @@ private void handleGroupDiscussion(ResolvedTarget resolved, String channelId, // Create the listener that streams discussion into Slack var listener = new SlackGroupDiscussionListener(slackApi, token, channelId, threadTs); - String question = resolved.strippedMessage(); + String question = resolved.strippedMessage() != null ? resolved.strippedMessage() : originalText; try { LOGGER.infof("Starting group discussion in channel %s, group %s, question: %s", channelId, groupId, question.substring(0, Math.min(80, question.length()))); @@ -339,7 +343,8 @@ private boolean tryHandleAgentFollowUp(String parentTs, String channelId, String enrichedInput = buildFollowUpInput(ctx, text); // Route to the specific agent from the group discussion - String conversationId = getOrCreateConversation(agentId, userId, channelId, parentTs); + String intent = "channel:followup:" + channelId + ":" + parentTs; + String conversationId = getOrCreateConversation(agentId, userId, intent); String response = sendAndWait(conversationId, enrichedInput); postMessageChunked(channelId, threadTs, response); @@ -549,11 +554,12 @@ private void postHelp(String channelId, String threadTs) { sb.append("👋 *Available targets in this channel:*\n\n"); for (ChannelTarget target : config.getTargets()) { + String name = target.getName() != null ? target.getName() : "(unnamed)"; String type = target.getType() == ChannelTarget.TargetType.GROUP ? "group" : "agent"; - String isDefault = target.getName().equalsIgnoreCase(config.getDefaultTargetName()) + String isDefault = name.equalsIgnoreCase(config.getDefaultTargetName()) ? " _(default)_" : ""; - sb.append("• *").append(target.getName()).append("*").append(isDefault); + sb.append("• *").append(name).append("*").append(isDefault); sb.append(" [").append(type).append("]\n"); if (target.getTriggers() != null && !target.getTriggers().isEmpty()) { sb.append(" Triggers: "); diff --git a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java index 02503193f..5eecda509 100644 --- a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java @@ -2,15 +2,23 @@ import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; import ai.labs.eddi.configs.channels.model.ChannelTarget; +import ai.labs.eddi.engine.caching.ICache; +import ai.labs.eddi.engine.caching.ICacheFactory; import ai.labs.eddi.integrations.channels.ChannelTargetRouter.ResolvedTarget; 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 java.time.Duration; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; /** * Unit tests for trigger matching, default fallback, help detection, colon @@ -26,9 +34,10 @@ class ChannelTargetRouterTest { @BeforeEach void setUp() { - // Router is instantiated with null dependencies — we only test the - // matching logic (resolveFromIntegration) which doesn't hit any stores. - router = new ChannelTargetRouter(null, null, null, null, null); + ICacheFactory cacheFactory = mock(ICacheFactory.class); + when(cacheFactory.getCache(eq("channel-thread-locks"), any(Duration.class))) + .thenReturn(new MapCache<>()); + router = new ChannelTargetRouter(null, null, null, null, null, cacheFactory); integration = new ChannelIntegrationConfiguration(); integration.setName("Test Hub"); @@ -345,4 +354,49 @@ void singleTarget() { assertEquals("I need help with my order", result.strippedMessage()); } } + + // ─── Test helper: simple ConcurrentHashMap-based ICache ───── + + private static class MapCache extends ConcurrentHashMap implements ICache { + + @Override + public String getCacheName() { + return "test-cache"; + } + + @Override + public V put(K key, V value, long lifespan, TimeUnit unit) { + return put(key, value); + } + + @Override + public V putIfAbsent(K key, V value, long lifespan, TimeUnit unit) { + return putIfAbsent(key, value); + } + + @Override + public void putAll(Map map, long lifespan, TimeUnit unit) { + putAll(map); + } + + @Override + public V replace(K key, V value, long lifespan, TimeUnit unit) { + return replace(key, value); + } + + @Override + public boolean replace(K key, V oldValue, V value, long lifespan, TimeUnit unit) { + return replace(key, oldValue, value); + } + + @Override + public V put(K key, V value, long lifespan, TimeUnit lifespanUnit, long maxIdleTime, TimeUnit maxIdleTimeUnit) { + return put(key, value); + } + + @Override + public V putIfAbsent(K key, V value, long lifespan, TimeUnit lifespanUnit, long maxIdleTime, TimeUnit maxIdleTimeUnit) { + return putIfAbsent(key, value); + } + } } From e4e493ae024a05753ab69fc25ed81979a5deb1a9 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 12:03:10 +0200 Subject: [PATCH 08/35] fix(channels): address code review findings #1-12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix #1: Delete dead SlackChannelRouter + SlackChannelRouterTest - Class was @ApplicationScoped but never injected, causing double startup cost scanning all agents for channel connectors - Both files removed (265 + 351 LOC) Review fix #2: Migration tool now merges duplicate channelId entries - Old: created one config per (agent, channel) pair, last-write-wins - New: groups connectors by platformChannelId and creates a single multi-target config per channel with agent-derived trigger keywords - Multi-agent channels show mergedAgents in dry-run output Review fix #3: Deep-copy config before resolving secrets - resolvePlatformSecrets was mutating the store's instance in-place, which would leak plaintext secrets if a caching layer is added - Added deepCopyConfig() — router works on copies, REST returns vault references Review fix #5: Null/blank trigger guard in validation - Null triggers from loose JSON input now return 400 instead of NPE Review fix #6: Remove dead fields - Removed newStyleChannelIds (assigned but never read) - Removed cacheFactory field (only used in constructor) - Removed unused ConcurrentHashMap import Review fix #7/#12: Reject observeMode=true until implemented - Validation now blocks observeMode=true with clear error message Review fix #8: Preserve stack traces in router error logging - All LOGGER.warnf(msg, e.getMessage()) changed to LOGGER.warn(msg, e) for production diagnosability Review fix #10: Rename 'channelId' to 'resourceId' in MCP responses - Eliminates confusion between Slack channelId and Mongo resourceId Review fix #11: Fix deployAgent description typo - 'production' was listed twice in 4 environment descriptions Review fix #17: Temper platform-agnostic Javadoc claim - Javadoc now says 'currently Slack-only with platform-agnostic model' --- .../rest/RestChannelIntegrationStore.java | 12 + .../labs/eddi/engine/mcp/McpAdminTools.java | 188 ++++++---- .../channels/ChannelTargetRouter.java | 67 ++-- .../slack/SlackChannelRouter.java | 264 ------------- .../slack/SlackChannelRouterTest.java | 351 ------------------ 5 files changed, 168 insertions(+), 714 deletions(-) delete mode 100644 src/main/java/ai/labs/eddi/integrations/slack/SlackChannelRouter.java delete mode 100644 src/test/java/ai/labs/eddi/integrations/slack/SlackChannelRouterTest.java diff --git a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java index b757375c7..7252bde64 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java @@ -164,8 +164,20 @@ private void validateConfiguration(ChannelIntegrationConfiguration config) { throw new BadRequestException( "Target '" + target.getName() + "' must have a targetId."); } + // Observe mode is schema-ready but not yet implemented + if (target.isObserveMode()) { + throw new BadRequestException( + "Target '" + target.getName() + + "': observeMode is not yet implemented. " + + "Set observeMode to false or omit it."); + } if (target.getTriggers() != null) { for (String trigger : target.getTriggers()) { + if (trigger == null || trigger.isBlank()) { + throw new BadRequestException( + "Target '" + target.getName() + + "' contains a null or blank trigger keyword."); + } String normalized = trigger.toLowerCase().trim(); if (!allTriggers.add(normalized)) { throw new BadRequestException( diff --git a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java index f60f13818..f80f455b8 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java @@ -97,7 +97,7 @@ public McpAdminTools(IRestInterfaceFactory restInterfaceFactory, IRestAgentAdmin + "Returns the deployment status.") public String deployAgent(@ToolArg(description = "Agent ID (required)") String agentId, @ToolArg(description = "Version number to deploy (required)") Integer version, - @ToolArg(description = "Environment: 'production' (default), 'production', or 'test'") String environment) { + @ToolArg(description = "Environment: 'production' (default) or 'test'") String environment) { requireRole(identity, authEnabled, "eddi-admin"); try { var env = parseEnvironment(environment); @@ -147,7 +147,7 @@ public String deployAgent(@ToolArg(description = "Agent ID (required)") String a @Tool(name = "undeploy_agent", description = "Undeploy a Agent from an environment. Optionally end all active conversations.") public String undeployAgent(@ToolArg(description = "Agent ID (required)") String agentId, @ToolArg(description = "Version number to undeploy (required)") Integer version, - @ToolArg(description = "Environment: 'production' (default), 'production', or 'test'") String environment, + @ToolArg(description = "Environment: 'production' (default) or 'test'") String environment, @ToolArg(description = "End all active conversations? (default: false)") Boolean endConversations) { requireRole(identity, authEnabled, "eddi-admin"); try { @@ -166,7 +166,7 @@ public String undeployAgent(@ToolArg(description = "Agent ID (required)") String @Tool(name = "get_deployment_status", description = "Get the deployment status of a specific Agent version in an environment.") public String getDeploymentStatus(@ToolArg(description = "Agent ID (required)") String agentId, @ToolArg(description = "Version number (required)") Integer version, - @ToolArg(description = "Environment: 'production' (default), 'production', or 'test'") String environment) { + @ToolArg(description = "Environment: 'production' (default) or 'test'") String environment) { requireRole(identity, authEnabled, "eddi-admin"); try { var env = parseEnvironment(environment); @@ -938,7 +938,7 @@ public String createSchedule(@ToolArg(description = "Agent ID to trigger (requir @ToolArg(description = "Conversation strategy: 'new' or 'persistent' " + "(CRON defaults to 'new', HEARTBEAT defaults to 'persistent')") String conversationStrategy, @ToolArg(description = "User identity for the scheduled message (default: 'system:scheduler')") String userId, - @ToolArg(description = "Environment: 'production' (default), 'production', or 'test'") String environment) { + @ToolArg(description = "Environment: 'production' (default) or 'test'") String environment) { requireRole(identity, authEnabled, "eddi-admin"); if (agentId == null || agentId.isBlank()) return errorJson("agentId is required"); @@ -1193,24 +1193,24 @@ public String listChannelIntegrations( @Tool(name = "read_channel_integration", description = "Read a channel integration configuration by ID. " + "Returns the full config with targets, triggers, platformConfig, and observe mode settings.") public String readChannelIntegration( - @ToolArg(description = "Channel integration ID (required)") String channelId, + @ToolArg(description = "Channel integration resource ID (required)") String resourceId, @ToolArg(description = "Version number (default: latest)") Integer version) { requireRole(identity, authEnabled, "eddi-admin"); - if (channelId == null || channelId.isBlank()) - return errorJson("channelId is required"); + if (resourceId == null || resourceId.isBlank()) + return errorJson("resourceId is required"); try { var channelStore = getRestStore( ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); - int ver = version != null ? version : channelStore.getCurrentVersion(channelId); - var config = channelStore.readChannel(channelId, ver); + int ver = version != null ? version : channelStore.getCurrentVersion(resourceId); + var config = channelStore.readChannel(resourceId, ver); var result = new LinkedHashMap(); - result.put("channelId", channelId); + result.put("resourceId", resourceId); result.put("version", ver); result.put("configuration", config); return jsonSerialization.serialize(result); } catch (Exception e) { - LOGGER.error("MCP read_channel_integration failed for " + channelId, e); + LOGGER.error("MCP read_channel_integration failed for " + resourceId, e); return errorJson("Failed to read channel integration: " + e.getMessage()); } } @@ -1233,7 +1233,7 @@ public String createChannelIntegration( String newId = extractIdFromLocation(location); return resultJson("created", Map.of( - "channelId", newId != null ? newId : "unknown", + "resourceId", newId != null ? newId : "unknown", "name", channelConfig.getName() != null ? channelConfig.getName() : "", "channelType", channelConfig.getChannelType() != null ? channelConfig.getChannelType() : "", "targetCount", channelConfig.getTargets() != null ? channelConfig.getTargets().size() : 0, @@ -1247,12 +1247,12 @@ public String createChannelIntegration( @Tool(name = "update_channel_integration", description = "Update an existing channel integration configuration.") public String updateChannelIntegration( - @ToolArg(description = "Channel integration ID (required)") String channelId, + @ToolArg(description = "Channel integration resource ID (required)") String resourceId, @ToolArg(description = "Current version number (required)") Integer version, @ToolArg(description = "Full JSON configuration body (required)") String config) { requireRole(identity, authEnabled, "eddi-admin"); - if (channelId == null || channelId.isBlank()) - return errorJson("channelId is required"); + if (resourceId == null || resourceId.isBlank()) + return errorJson("resourceId is required"); if (config == null || config.isBlank()) return errorJson("config is required"); try { @@ -1261,50 +1261,50 @@ public String updateChannelIntegration( ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration.class); var channelStore = getRestStore( ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); - Response response = channelStore.updateChannel(channelId, ver, channelConfig); + Response response = channelStore.updateChannel(resourceId, ver, channelConfig); String location = response.getHeaderString("Location"); int newVersion = extractVersionFromLocation(location); return resultJson("updated", Map.of( - "channelId", channelId, + "resourceId", resourceId, "previousVersion", ver, "newVersion", newVersion, "status", response.getStatus())); } catch (Exception e) { - LOGGER.error("MCP update_channel_integration failed for " + channelId, e); + LOGGER.error("MCP update_channel_integration failed for " + resourceId, e); return errorJson("Failed to update channel integration: " + e.getMessage()); } } @Tool(name = "delete_channel_integration", description = "Delete a channel integration configuration.") public String deleteChannelIntegration( - @ToolArg(description = "Channel integration ID (required)") String channelId, + @ToolArg(description = "Channel integration resource ID (required)") String resourceId, @ToolArg(description = "Current version number (required)") Integer version, @ToolArg(description = "Permanently delete? (default: false)") Boolean permanent) { requireRole(identity, authEnabled, "eddi-admin"); - if (channelId == null || channelId.isBlank()) - return errorJson("channelId is required"); + if (resourceId == null || resourceId.isBlank()) + return errorJson("resourceId is required"); try { int ver = version != null ? version : 1; boolean isPermanent = permanent != null ? permanent : false; var channelStore = getRestStore( ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); - Response response = channelStore.deleteChannel(channelId, ver, isPermanent); + Response response = channelStore.deleteChannel(resourceId, ver, isPermanent); return resultJson("deleted", Map.of( - "channelId", channelId, + "resourceId", resourceId, "version", ver, "permanent", isPermanent, "status", response.getStatus())); } catch (Exception e) { - LOGGER.error("MCP delete_channel_integration failed for " + channelId, e); + LOGGER.error("MCP delete_channel_integration failed for " + resourceId, e); return errorJson("Failed to delete channel integration: " + e.getMessage()); } } @Tool(name = "migrate_channel_connectors", description = "Migrate legacy ChannelConnector entries from agent configs " - + "to standalone ChannelIntegrationConfigurations. Scans all deployed agents and creates one " - + "ChannelIntegrationConfiguration per unique channelId. Non-destructive (does not modify agent configs). " + + "to standalone ChannelIntegrationConfigurations. Scans all deployed agents and merges entries for the same " + + "channelId into a single multi-target config. Non-destructive (does not modify agent configs). " + "Run this once to upgrade from the old channel model.") public String migrateChannelConnectors( @ToolArg(description = "Dry run mode — show what would be created without creating (default: true)") Boolean dryRun) { @@ -1317,8 +1317,10 @@ public String migrateChannelConnectors( var statuses = agentAdmin.getDeploymentStatuses( ai.labs.eddi.engine.model.Deployment.Environment.production); - var migrated = new ArrayList>(); - var skipped = new ArrayList>(); + + // Group connectors by platformChannelId to detect duplicates + // channelId → list of (connector, agentId, agentName) + var channelGroups = new LinkedHashMap>>(); for (var status : statuses) { if (status.getDescriptor() == null || status.getDescriptor().isDeleted()) @@ -1333,58 +1335,96 @@ public String migrateChannelConnectors( for (var connector : agentConfig.getChannels()) { if (connector.getType() == null || connector.getConfig() == null) continue; - String channelType = connector.getType().toString().toLowerCase(); String platformChannelId = connector.getConfig().get("channelId"); if (platformChannelId == null || platformChannelId.isBlank()) continue; - // Build a ChannelIntegrationConfiguration - var newConfig = new ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration(); - String agentName = status.getDescriptor().getName(); - newConfig.setName(channelType + " — " - + (agentName != null ? agentName : agentId)); - newConfig.setChannelType(channelType); - newConfig.setPlatformConfig(new java.util.HashMap<>(connector.getConfig())); - - // Create a default target pointing to the agent - var target = new ai.labs.eddi.configs.channels.model.ChannelTarget(); - target.setName("default"); - target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.AGENT); - target.setTargetId(agentId); - // If groupId present, make it a group target - String groupId = connector.getConfig().get("groupId"); - if (groupId != null && !groupId.isBlank()) { - target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.GROUP); - target.setTargetId(groupId); - } - newConfig.setTargets(List.of(target)); - newConfig.setDefaultTargetName("default"); - - var entry = new LinkedHashMap(); - entry.put("agentId", agentId); - entry.put("channelType", channelType); - entry.put("platformChannelId", platformChannelId); - - if (isDryRun) { - entry.put("action", "would_create"); - entry.put("config", newConfig); - migrated.add(entry); - } else { - try { - Response response = channelStore.createChannel(newConfig); - String location = response.getHeaderString("Location"); - entry.put("action", "created"); - entry.put("location", location); - migrated.add(entry); - } catch (Exception createErr) { - entry.put("action", "failed"); - entry.put("error", createErr.getMessage()); - skipped.add(entry); - } - } + channelGroups.computeIfAbsent(platformChannelId, k -> new ArrayList<>()) + .add(Map.of( + "connector", connector, + "agentId", agentId, + "agentName", status.getDescriptor().getName() != null + ? status.getDescriptor().getName() + : agentId, + "channelType", connector.getType().toString().toLowerCase())); } } catch (Exception e) { - skipped.add(Map.of("agentId", agentId, "error", e.getMessage())); + // skip + } + } + + var migrated = new ArrayList>(); + var skipped = new ArrayList>(); + + for (var entry : channelGroups.entrySet()) { + String platformChannelId = entry.getKey(); + List> connectors = entry.getValue(); + + // Build a single multi-target config for this channelId + var firstConnector = (AgentConfiguration.ChannelConnector) connectors.get(0).get("connector"); + String channelType = (String) connectors.get(0).get("channelType"); + + var newConfig = new ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration(); + newConfig.setName(channelType + " — " + platformChannelId); + newConfig.setChannelType(channelType); + newConfig.setPlatformConfig(new java.util.HashMap<>(firstConnector.getConfig())); + + var targets = new ArrayList(); + String firstTargetName = null; + + for (var connectorEntry : connectors) { + String agentId = (String) connectorEntry.get("agentId"); + String agentName = (String) connectorEntry.get("agentName"); + var connector = (AgentConfiguration.ChannelConnector) connectorEntry.get("connector"); + + var target = new ai.labs.eddi.configs.channels.model.ChannelTarget(); + // Use agent name as target name (sanitized) + String targetName = agentName.toLowerCase().replaceAll("[^a-z0-9-]", "-"); + target.setName(targetName); + target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.AGENT); + target.setTargetId(agentId); + + String groupId = connector.getConfig().get("groupId"); + if (groupId != null && !groupId.isBlank()) { + target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.GROUP); + target.setTargetId(groupId); + } + + // Add trigger from target name + target.setTriggers(List.of(targetName)); + targets.add(target); + if (firstTargetName == null) + firstTargetName = targetName; + } + + newConfig.setTargets(targets); + newConfig.setDefaultTargetName(firstTargetName); + + var resultEntry = new LinkedHashMap(); + resultEntry.put("platformChannelId", platformChannelId); + resultEntry.put("channelType", channelType); + resultEntry.put("targetCount", targets.size()); + if (connectors.size() > 1) { + resultEntry.put("mergedAgents", connectors.stream() + .map(c -> (String) c.get("agentId")).toList()); + } + + if (isDryRun) { + resultEntry.put("action", "would_create"); + resultEntry.put("config", newConfig); + migrated.add(resultEntry); + } else { + try { + Response response = channelStore.createChannel(newConfig); + String location = response.getHeaderString("Location"); + resultEntry.put("action", "created"); + resultEntry.put("location", location); + migrated.add(resultEntry); + } catch (Exception createErr) { + resultEntry.put("action", "failed"); + resultEntry.put("error", createErr.getMessage()); + skipped.add(resultEntry); + } } } diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index d1b1c9c29..98fbefbd5 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -19,19 +19,19 @@ import org.jboss.logging.Logger; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.time.Duration; import static ai.labs.eddi.utils.RestUtilities.extractResourceId; /** - * Platform-agnostic target router for channel integrations. Resolves incoming - * channel messages to the correct {@link ChannelTarget} based on configured - * trigger keywords (colon-required syntax: {@code keyword: message}). + * Target router for channel integrations. Resolves incoming channel messages to + * the correct {@link ChannelTarget} based on configured trigger keywords + * (colon-required syntax: {@code keyword: message}). *

- * Replaces {@code SlackChannelRouter} and is shared across all platform - * adapters (Slack, Teams, Discord). + * Currently Slack-only with a platform-agnostic internal model; Teams/Discord + * adapters will extend the platform-specific paths (signing secret aggregation, + * legacy fallback) when added. *

* Fallback rule: If any {@code ChannelIntegrationConfiguration} matches * a channelId, ALL legacy {@code ChannelConnector} entries for that channel are @@ -53,12 +53,13 @@ public class ChannelTargetRouter { private final IRestAgentStore agentStore; private final SecretResolver secretResolver; - private final ICacheFactory cacheFactory; - // ─── Cached state (atomic reference swap) ────────────────────────────────── /** - * channelType:channelId → ChannelIntegrationConfiguration (resolved secrets). + * channelType:channelId → deep-copied ChannelIntegrationConfiguration with + * resolved secrets. These instances are never returned to callers outside the + * router — the REST layer reads from the store directly and returns vault + * references. */ private volatile Map integrationMap = Map.of(); @@ -68,9 +69,6 @@ public class ChannelTargetRouter { /** Legacy channelId → LegacyTarget for backward compat. */ private volatile Map legacyMap = Map.of(); - /** Channel IDs covered by new-style ChannelIntegrationConfiguration. */ - private volatile Set newStyleChannelIds = Set.of(); - private volatile long lastRefreshTime = 0; private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); @@ -91,7 +89,6 @@ public ChannelTargetRouter(IChannelIntegrationStore channelStore, this.agentAdmin = agentAdmin; this.agentStore = agentStore; this.secretResolver = secretResolver; - this.cacheFactory = cacheFactory; this.threadTargetLock = cacheFactory.getCache("channel-thread-locks", Duration.ofHours(24)); } @@ -276,7 +273,7 @@ private void refreshIfNeeded() { refreshInternal(); lastRefreshTime = now; } catch (Exception e) { - LOGGER.warnf("Failed to refresh channel target router: %s", e.getMessage()); + LOGGER.warn("Failed to refresh channel target router", e); lastRefreshTime = now; // Avoid hammering on repeated failures } finally { refreshInProgress.set(false); @@ -300,12 +297,14 @@ private void refreshInternal() { if (config != null && config.getChannelType() != null && config.getPlatformConfig() != null) { - // Resolve secrets in platformConfig + // Deep-copy before resolving secrets so the store's + // cached instance keeps vault references intact String channelId = config.getPlatformConfig().get("channelId"); if (channelId != null && !channelId.isBlank()) { - resolvePlatformSecrets(config); - String key = config.getChannelType().toLowerCase() + ":" + channelId; - newIntegrationMap.put(key, config); + var copy = deepCopyConfig(config); + resolvePlatformSecrets(copy); + String key = copy.getChannelType().toLowerCase() + ":" + channelId; + newIntegrationMap.put(key, copy); coveredChannelIds.add(channelId); // Collect signing secrets for Slack @@ -319,11 +318,11 @@ private void refreshInternal() { } } } catch (Exception e) { - LOGGER.debugf("Skipping channel config: %s", e.getMessage()); + LOGGER.debug("Skipping channel config", e); } } } catch (Exception e) { - LOGGER.warnf("Failed to load channel integration configs: %s", e.getMessage()); + LOGGER.warn("Failed to load channel integration configs", e); } // 2. Load legacy ChannelConnector entries (backward compat) @@ -366,24 +365,42 @@ private void refreshInternal() { } } } catch (Exception e) { - LOGGER.debugf("Skipping agent %s for legacy channel scan: %s", - agentId, e.getMessage()); + LOGGER.debugf(e, "Skipping agent %s for legacy channel scan", + agentId); } } } catch (Exception e) { - LOGGER.warnf("Failed to scan legacy ChannelConnectors: %s", e.getMessage()); + LOGGER.warn("Failed to scan legacy ChannelConnectors", e); } // Atomic swap integrationMap = Map.copyOf(newIntegrationMap); legacyMap = Map.copyOf(newLegacyMap); slackSigningSecrets = Set.copyOf(newSigningSecrets); - newStyleChannelIds = Set.copyOf(coveredChannelIds); LOGGER.debugf("Channel target router refreshed: %d integrations, %d legacy, %d signing secrets", newIntegrationMap.size(), newLegacyMap.size(), newSigningSecrets.size()); } + /** + * Deep-copy a config so that secret resolution does not mutate the store's + * cached instance (which must retain {@code ${eddivault:...}} references for + * the REST API). + */ + private ChannelIntegrationConfiguration deepCopyConfig(ChannelIntegrationConfiguration src) { + var copy = new ChannelIntegrationConfiguration(); + copy.setName(src.getName()); + copy.setChannelType(src.getChannelType()); + copy.setDefaultTargetName(src.getDefaultTargetName()); + if (src.getPlatformConfig() != null) { + copy.setPlatformConfig(new HashMap<>(src.getPlatformConfig())); + } + if (src.getTargets() != null) { + copy.setTargets(new ArrayList<>(src.getTargets())); + } + return copy; + } + private void resolvePlatformSecrets(ChannelIntegrationConfiguration config) { Map resolved = new HashMap<>(); for (var entry : config.getPlatformConfig().entrySet()) { @@ -398,7 +415,7 @@ private String resolveSecret(String value) { try { return secretResolver.resolveValue(value); } catch (Exception e) { - LOGGER.warnf("Failed to resolve secret: %s", e.getMessage()); + LOGGER.warn("Failed to resolve secret", e); return null; } } diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackChannelRouter.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackChannelRouter.java deleted file mode 100644 index e0198448d..000000000 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackChannelRouter.java +++ /dev/null @@ -1,264 +0,0 @@ -package ai.labs.eddi.integrations.slack; - -import ai.labs.eddi.configs.agents.IRestAgentStore; -import ai.labs.eddi.configs.agents.model.AgentConfiguration; -import ai.labs.eddi.configs.agents.model.AgentConfiguration.ChannelConnector; -import ai.labs.eddi.engine.api.IRestAgentAdministration; -import ai.labs.eddi.engine.model.AgentDeploymentStatus; -import ai.labs.eddi.engine.model.Deployment; -import ai.labs.eddi.secrets.SecretResolver; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import org.jboss.logging.Logger; - -import java.util.*; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * Routes incoming Slack channel messages to the correct EDDI agent and resolves - * per-agent credentials by scanning deployed agents for - * {@link ChannelConnector} entries of type {@code "slack"}. - *

- * All Slack credentials (bot token, signing secret) live in the agent's - * {@code ChannelConnector.config} map and are resolved via - * {@link SecretResolver} (supporting {@code ${eddivault:...}} references). - *

- * Resolution order for agent routing: - *

    - *
  1. Check channel→agent map (built from ChannelConnector configs)
  2. - *
  3. Return empty if no match
  4. - *
- * - * @since 6.0.0 - */ -@ApplicationScoped -public class SlackChannelRouter { - - private static final Logger LOGGER = Logger.getLogger(SlackChannelRouter.class); - private static final String CHANNEL_TYPE_SLACK = "slack"; - - private final IRestAgentAdministration agentAdmin; - private final IRestAgentStore agentStore; - private final SecretResolver secretResolver; - - /** - * Resolved credentials for a Slack channel connector. - * - * @param agentId - * the EDDI agent ID - * @param botToken - * the resolved bot token (plaintext) - * @param signingSecret - * the resolved signing secret (plaintext) - * @param groupId - * optional group ID for multi-agent discussions - */ - public record SlackCredentials(String agentId, String botToken, String signingSecret, String groupId) { - } - - /** - * channelId → full credentials mapping, rebuilt on demand. Volatile reference - * swap ensures concurrent readers never see a partially-updated map. - */ - private volatile Map channelCredentialsMap = Map.of(); - - /** - * All unique signing secrets across all configured agents. Used for webhook - * signature verification (try all secrets since we don't know the workspace - * before verification). - */ - private volatile Set allSigningSecrets = Set.of(); - - private volatile long lastRefreshTime = 0; - private static final long REFRESH_INTERVAL_MS = 60_000; // 1 minute - private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); - - @Inject - public SlackChannelRouter(IRestAgentAdministration agentAdmin, IRestAgentStore agentStore, - SecretResolver secretResolver) { - this.agentAdmin = agentAdmin; - this.agentStore = agentStore; - this.secretResolver = secretResolver; - } - - /** - * Resolve which EDDI agent should handle messages from a given Slack channel. - * - * @param slackChannelId - * the Slack channel ID (e.g., "C0123ABCDEF") - * @return the EDDI agent ID, or empty if no mapping exists - */ - public Optional resolveAgentId(String slackChannelId) { - refreshIfNeeded(); - SlackCredentials creds = channelCredentialsMap.get(slackChannelId); - return creds != null ? Optional.of(creds.agentId()) : Optional.empty(); - } - - /** - * Resolve which group configuration to use for group discussions from a given - * Slack channel. - * - * @param slackChannelId - * the Slack channel ID - * @return the EDDI group config ID, or empty if no mapping exists - */ - public Optional resolveGroupId(String slackChannelId) { - refreshIfNeeded(); - SlackCredentials creds = channelCredentialsMap.get(slackChannelId); - return creds != null && creds.groupId() != null ? Optional.of(creds.groupId()) : Optional.empty(); - } - - /** - * Resolve the full credentials for a Slack channel. Returns the bot token, - * signing secret, agent ID, and optional group ID. - * - * @param slackChannelId - * the Slack channel ID - * @return the resolved credentials, or empty if no mapping exists - */ - public Optional resolveCredentials(String slackChannelId) { - refreshIfNeeded(); - return Optional.ofNullable(channelCredentialsMap.get(slackChannelId)); - } - - /** - * Get all known signing secrets across all configured agents. Used by the - * webhook endpoint for signature verification — the verifier tries each secret - * until one matches (standard multi-workspace Slack pattern). - * - * @return an unmodifiable set of resolved signing secrets (never null, may be - * empty) - */ - public Set getAllSigningSecrets() { - refreshIfNeeded(); - return allSigningSecrets; - } - - /** - * Check if any Slack channel connectors are configured across all deployed - * agents. - * - * @return true if at least one agent has a Slack channel connector - */ - public boolean hasAnySlackChannels() { - refreshIfNeeded(); - return !channelCredentialsMap.isEmpty(); - } - - /** - * Refresh the channel→credentials mapping by scanning deployed agents. Uses a - * simple time-based cache invalidation (1 minute). Vault references - * (${eddivault:...}) are resolved during refresh via {@link SecretResolver}. - */ - private void refreshIfNeeded() { - long now = System.currentTimeMillis(); - if (now - lastRefreshTime < REFRESH_INTERVAL_MS) { - return; - } - - // Gate: only one thread refreshes at a time - if (!refreshInProgress.compareAndSet(false, true)) { - return; - } - - try { - var newCredentialsMap = new HashMap(); - var newSigningSecrets = new HashSet(); - - // Scan all deployed agents in production - List statuses = agentAdmin.getDeploymentStatuses(Deployment.Environment.production); - for (AgentDeploymentStatus status : statuses) { - if (status.getDescriptor() == null || status.getDescriptor().isDeleted()) { - continue; - } - - String agentId = status.getAgentId(); - int version = status.getAgentVersion(); - - try { - AgentConfiguration agentConfig = agentStore.readAgent(agentId, version); - if (agentConfig != null && agentConfig.getChannels() != null) { - for (ChannelConnector channel : agentConfig.getChannels()) { - if (channel.getType() != null - && channel.getType().toString().equalsIgnoreCase(CHANNEL_TYPE_SLACK) - && channel.getConfig() != null) { - - processSlackChannel(agentId, channel.getConfig(), - newCredentialsMap, newSigningSecrets); - } - } - } - } catch (Exception e) { - LOGGER.debugf("Could not read agent config for %s v%d: %s", agentId, version, e.getMessage()); - } - } - - // Atomic reference swap — readers never see a partially-updated map - channelCredentialsMap = Map.copyOf(newCredentialsMap); - allSigningSecrets = Set.copyOf(newSigningSecrets); - lastRefreshTime = now; - - LOGGER.infof("Slack channel router refreshed: %d channel mappings, %d unique signing secrets", - newCredentialsMap.size(), newSigningSecrets.size()); - } catch (Exception e) { - LOGGER.warnf("Failed to refresh Slack channel router: %s", e.getMessage()); - lastRefreshTime = now; // Avoid hammering on repeated failures - } finally { - refreshInProgress.set(false); - } - } - - /** - * Process a single Slack ChannelConnector config entry, resolving vault - * references and building the credentials mapping. - */ - private void processSlackChannel(String agentId, Map config, - Map credentialsMap, Set signingSecrets) { - - String channelId = config.get("channelId"); - if (channelId == null || channelId.isBlank()) { - LOGGER.debugf("Slack ChannelConnector on agent %s has no channelId — skipping", agentId); - return; - } - - // Resolve vault references for credentials - String botToken = resolveSecret(config.get("botToken"), agentId, "botToken"); - String signingSecret = resolveSecret(config.get("signingSecret"), agentId, "signingSecret"); - String groupId = config.get("groupId"); - - if (botToken == null || botToken.isBlank()) { - LOGGER.warnf("Slack ChannelConnector on agent %s, channel %s: botToken is missing or unresolved", - agentId, channelId); - } - - if (signingSecret == null || signingSecret.isBlank()) { - LOGGER.warnf("Slack ChannelConnector on agent %s, channel %s: signingSecret is missing or unresolved", - agentId, channelId); - } - - credentialsMap.put(channelId, new SlackCredentials(agentId, botToken, signingSecret, - groupId != null && !groupId.isBlank() ? groupId : null)); - LOGGER.debugf("Mapped Slack channel %s → agent %s", channelId, agentId); - - if (signingSecret != null && !signingSecret.isBlank()) { - signingSecrets.add(signingSecret); - } - } - - /** - * Resolve a config value that may be a vault reference. Returns the resolved - * plaintext value, or the original value if vault is not configured. Returns - * null if the value is null. - */ - private String resolveSecret(String value, String agentId, String fieldName) { - if (value == null || value.isBlank()) { - return null; - } - try { - return secretResolver.resolveValue(value); - } catch (Exception e) { - LOGGER.warnf("Failed to resolve %s for agent %s: %s", fieldName, agentId, e.getMessage()); - return null; - } - } -} diff --git a/src/test/java/ai/labs/eddi/integrations/slack/SlackChannelRouterTest.java b/src/test/java/ai/labs/eddi/integrations/slack/SlackChannelRouterTest.java deleted file mode 100644 index 0d3b9c052..000000000 --- a/src/test/java/ai/labs/eddi/integrations/slack/SlackChannelRouterTest.java +++ /dev/null @@ -1,351 +0,0 @@ -package ai.labs.eddi.integrations.slack; - -import ai.labs.eddi.configs.agents.IRestAgentStore; -import ai.labs.eddi.configs.agents.model.AgentConfiguration; -import ai.labs.eddi.configs.agents.model.AgentConfiguration.ChannelConnector; -import ai.labs.eddi.configs.descriptors.model.DocumentDescriptor; -import ai.labs.eddi.engine.api.IRestAgentAdministration; -import ai.labs.eddi.engine.model.AgentDeploymentStatus; -import ai.labs.eddi.engine.model.Deployment; -import ai.labs.eddi.secrets.SecretResolver; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.net.URI; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - -/** - * Tests for {@link SlackChannelRouter}. - */ -class SlackChannelRouterTest { - - private IRestAgentAdministration agentAdmin; - private IRestAgentStore agentStore; - private SecretResolver secretResolver; - private SlackChannelRouter router; - - @BeforeEach - void setUp() { - agentAdmin = mock(IRestAgentAdministration.class); - agentStore = mock(IRestAgentStore.class); - secretResolver = mock(SecretResolver.class); - - // By default, SecretResolver passes through unchanged - when(secretResolver.resolveValue(anyString())).thenAnswer(inv -> inv.getArgument(0)); - - router = new SlackChannelRouter(agentAdmin, agentStore, secretResolver); - } - - // ─── Agent Resolution ─── - - @Test - void resolveAgentId_explicitMapping_returnsAgentId() throws Exception { - setupDeployedAgent("agent-1", 1, "C0123", "xoxb-token", "signing-secret", null); - assertEquals(Optional.of("agent-1"), router.resolveAgentId("C0123")); - } - - @Test - void resolveAgentId_noMapping_returnsEmpty() throws Exception { - when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) - .thenReturn(List.of()); - - assertEquals(Optional.empty(), router.resolveAgentId("C_UNKNOWN")); - } - - // ─── Group Resolution ─── - - @Test - void resolveGroupId_explicitMapping_returnsGroupId() throws Exception { - setupDeployedAgent("agent-1", 1, "C0123", "xoxb-token", "signing-secret", "group-42"); - assertEquals(Optional.of("group-42"), router.resolveGroupId("C0123")); - } - - @Test - void resolveGroupId_noGroupMapping_returnsEmpty() throws Exception { - setupDeployedAgent("agent-1", 1, "C0123", "xoxb-token", "signing-secret", null); - assertEquals(Optional.empty(), router.resolveGroupId("C0123")); - } - - // ─── Credentials Resolution ─── - - @Test - void resolveCredentials_returnsFullCredentials() throws Exception { - setupDeployedAgent("agent-1", 1, "C0123", "xoxb-my-token", "my-signing-secret", "group-42"); - - var credsOpt = router.resolveCredentials("C0123"); - assertTrue(credsOpt.isPresent()); - - var creds = credsOpt.get(); - assertEquals("agent-1", creds.agentId()); - assertEquals("xoxb-my-token", creds.botToken()); - assertEquals("my-signing-secret", creds.signingSecret()); - assertEquals("group-42", creds.groupId()); - } - - @Test - void resolveCredentials_unknownChannel_returnsEmpty() throws Exception { - when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) - .thenReturn(List.of()); - - assertTrue(router.resolveCredentials("C_UNKNOWN").isEmpty()); - } - - @Test - void resolveCredentials_vaultReferencesResolved() throws Exception { - // Configure SecretResolver to resolve vault references - when(secretResolver.resolveValue("${eddivault:slack-token}")).thenReturn("xoxb-resolved"); - when(secretResolver.resolveValue("${eddivault:slack-secret}")).thenReturn("resolved-secret"); - - setupDeployedAgent("agent-1", 1, "C0123", - "${eddivault:slack-token}", "${eddivault:slack-secret}", null); - - var creds = router.resolveCredentials("C0123"); - assertTrue(creds.isPresent()); - assertEquals("xoxb-resolved", creds.get().botToken()); - assertEquals("resolved-secret", creds.get().signingSecret()); - } - - // ─── Signing Secrets ─── - - @Test - void getAllSigningSecrets_returnsAllUniqueSecrets() throws Exception { - setupDeployedAgents( - new AgentSpec("agent-1", 1, "C0001", "token-1", "secret-A", null), - new AgentSpec("agent-2", 1, "C0002", "token-2", "secret-B", null)); - - var secrets = router.getAllSigningSecrets(); - assertEquals(2, secrets.size()); - assertTrue(secrets.contains("secret-A")); - assertTrue(secrets.contains("secret-B")); - } - - @Test - void getAllSigningSecrets_deduplicatesSameSecret() throws Exception { - // Two agents using the same workspace (same signing secret) - setupDeployedAgents( - new AgentSpec("agent-1", 1, "C0001", "token-1", "shared-secret", null), - new AgentSpec("agent-2", 1, "C0002", "token-2", "shared-secret", null)); - - var secrets = router.getAllSigningSecrets(); - assertEquals(1, secrets.size()); - assertTrue(secrets.contains("shared-secret")); - } - - @Test - void getAllSigningSecrets_emptyWhenNoAgents() throws Exception { - when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) - .thenReturn(List.of()); - - assertTrue(router.getAllSigningSecrets().isEmpty()); - } - - // ─── Edge Cases ─── - - @Test - void resolveAgentId_deletedAgent_ignored() throws Exception { - var descriptor = new DocumentDescriptor(); - descriptor.setDeleted(true); - - var status = new AgentDeploymentStatus(); - status.setAgentId("deleted-agent"); - status.setAgentVersion(1); - status.setDescriptor(descriptor); - - when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) - .thenReturn(List.of(status)); - - assertEquals(Optional.empty(), router.resolveAgentId("C0123")); - } - - @Test - void resolveAgentId_noChannels_ignored() throws Exception { - var descriptor = new DocumentDescriptor(); - descriptor.setDeleted(false); - - var status = new AgentDeploymentStatus(); - status.setAgentId("agent-no-channels"); - status.setAgentVersion(1); - status.setDescriptor(descriptor); - - when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) - .thenReturn(List.of(status)); - when(agentStore.readAgent("agent-no-channels", 1)) - .thenReturn(new AgentConfiguration()); - - assertEquals(Optional.empty(), router.resolveAgentId("C0123")); - } - - @Test - void resolveAgentId_multipleAgents_lastChannelWins() throws Exception { - // Both agents map to the same channelId — last one scanned wins - setupDeployedAgents( - new AgentSpec("agent-1", 1, "C_SHARED", "token-1", "secret-1", null), - new AgentSpec("agent-2", 1, "C_SHARED", "token-2", "secret-2", null)); - - var result = router.resolveAgentId("C_SHARED"); - assertTrue(result.isPresent()); - // Either agent-1 or agent-2 wins — the key behavior is that it doesn't throw - } - - @Test - void resolveAgentId_cacheRefresh_skipsWhenRecent() throws Exception { - setupDeployedAgent("agent-1", 1, "C0123", "token", "secret", null); - - // First call triggers refresh - router.resolveAgentId("C0123"); - // Second call should use cached data - router.resolveAgentId("C0123"); - - // getDeploymentStatuses should only be called once (cached for 60s) - verify(agentAdmin, times(1)).getDeploymentStatuses(any()); - } - - @Test - void resolveAgentId_missingBotToken_stillMapsAgent() throws Exception { - // Agent configured without botToken — still routes, but posting will warn - setupDeployedAgent("agent-1", 1, "C0123", null, "secret", null); - - assertEquals(Optional.of("agent-1"), router.resolveAgentId("C0123")); - var creds = router.resolveCredentials("C0123"); - assertTrue(creds.isPresent()); - assertNull(creds.get().botToken()); - } - - @Test - void hasAnySlackChannels_trueWhenConfigured() throws Exception { - setupDeployedAgent("agent-1", 1, "C0123", "token", "secret", null); - assertTrue(router.hasAnySlackChannels()); - } - - @Test - void hasAnySlackChannels_falseWhenEmpty() throws Exception { - when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) - .thenReturn(List.of()); - assertFalse(router.hasAnySlackChannels()); - } - - @Test - void resolveCredentials_vaultFailure_botTokenNull_channelStillMapped() throws Exception { - // Vault throws for the botToken reference but signingSecret resolves fine - when(secretResolver.resolveValue("${eddivault:bad-token-ref}")) - .thenThrow(new RuntimeException("Vault key not found: bad-token-ref")); - when(secretResolver.resolveValue("plain-secret")) - .thenReturn("plain-secret"); - - setupDeployedAgent("agent-1", 1, "C0123", - "${eddivault:bad-token-ref}", "plain-secret", null); - - // Channel should still be mapped (routing works) - assertEquals(Optional.of("agent-1"), router.resolveAgentId("C0123")); - - // Credentials should exist but botToken should be null (graceful degradation) - var creds = router.resolveCredentials("C0123"); - assertTrue(creds.isPresent()); - assertNull(creds.get().botToken()); - assertEquals("plain-secret", creds.get().signingSecret()); - } - - @Test - void resolveCredentials_vaultFailure_signingSecretNull_notInSigningSecretsSet() throws Exception { - // Signing secret vault ref fails — should not appear in getAllSigningSecrets() - when(secretResolver.resolveValue("xoxb-good-token")) - .thenReturn("xoxb-good-token"); - when(secretResolver.resolveValue("${eddivault:bad-secret-ref}")) - .thenThrow(new RuntimeException("Vault key not found")); - - setupDeployedAgent("agent-1", 1, "C0123", - "xoxb-good-token", "${eddivault:bad-secret-ref}", null); - - // Signing secrets set should be empty (failed resolution excluded) - assertTrue(router.getAllSigningSecrets().isEmpty()); - - // But the channel is still mapped - assertEquals(Optional.of("agent-1"), router.resolveAgentId("C0123")); - } - - // ─── Helpers ─── - - private void setupDeployedAgent(String agentId, int version, String channelId, - String botToken, String signingSecret, String groupId) - throws Exception { - var descriptor = new DocumentDescriptor(); - descriptor.setDeleted(false); - - var status = new AgentDeploymentStatus(); - status.setAgentId(agentId); - status.setAgentVersion(version); - status.setDescriptor(descriptor); - - when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) - .thenReturn(List.of(status)); - - var channelConfig = new java.util.HashMap(); - channelConfig.put("channelId", channelId); - if (botToken != null) { - channelConfig.put("botToken", botToken); - } - if (signingSecret != null) { - channelConfig.put("signingSecret", signingSecret); - } - if (groupId != null) { - channelConfig.put("groupId", groupId); - } - - var channel = new ChannelConnector(); - channel.setType(URI.create("slack")); - channel.setConfig(channelConfig); - - var agentConfig = new AgentConfiguration(); - agentConfig.setChannels(List.of(channel)); - - when(agentStore.readAgent(agentId, version)).thenReturn(agentConfig); - } - - private record AgentSpec(String agentId, int version, String channelId, - String botToken, String signingSecret, String groupId) { - } - - private void setupDeployedAgents(AgentSpec... specs) throws Exception { - var statuses = new java.util.ArrayList(); - - for (var spec : specs) { - var descriptor = new DocumentDescriptor(); - descriptor.setDeleted(false); - - var status = new AgentDeploymentStatus(); - status.setAgentId(spec.agentId()); - status.setAgentVersion(spec.version()); - status.setDescriptor(descriptor); - statuses.add(status); - - var channelConfig = new java.util.HashMap(); - channelConfig.put("channelId", spec.channelId()); - if (spec.botToken() != null) { - channelConfig.put("botToken", spec.botToken()); - } - if (spec.signingSecret() != null) { - channelConfig.put("signingSecret", spec.signingSecret()); - } - if (spec.groupId() != null) { - channelConfig.put("groupId", spec.groupId()); - } - - var channel = new ChannelConnector(); - channel.setType(URI.create("slack")); - channel.setConfig(channelConfig); - - var agentConfig = new AgentConfiguration(); - agentConfig.setChannels(List.of(channel)); - - when(agentStore.readAgent(spec.agentId(), spec.version())).thenReturn(agentConfig); - } - - when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) - .thenReturn(statuses); - } -} From a45bee1f6e6e0786fa77f30d0411fe4620754796 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 12:04:46 +0200 Subject: [PATCH 09/35] docs(channels): add code review hardening changelog entry --- docs/changelog.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 8546ae9d5..37fdbfa5a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,6 +13,30 @@ Each entry follows this format: - **Decision** — Key design decisions and their reasoning - **Files** — Links to modified files +## Channel Integration — Code Review Hardening (2026-04-18) + +**Repo:** EDDI (`feature/channel-integrations`) + +**What changed:** Addressed 12 findings from a thorough code review before merge. + +### Critical fixes +- **Deleted dead `SlackChannelRouter`** (#1) — was `@ApplicationScoped` but never injected, causing double agent scanning at startup. Removed 615 LOC (class + test). +- **Migration now merges duplicate channelIds** (#2) — old tool created one config per (agent, channel) pair; new version groups by platformChannelId and creates a single multi-target config with derived triggers. +- **Deep-copy before secret resolution** (#3) — `resolvePlatformSecrets` was mutating the store's instance in-place; added `deepCopyConfig()` so the REST layer always returns vault references. +- **Null/blank trigger guard** (#5) — null triggers from loose JSON now return 400 instead of NPE. +- **Removed dead fields** (#6) — `newStyleChannelIds` (assigned, never read), `cacheFactory` (constructor-only), unused `ConcurrentHashMap` import. +- **Reject `observeMode=true`** (#12) — validation now blocks until the feature is implemented. +- **Stack traces preserved** (#8) — all `LOGGER.warnf(msg, e.getMessage())` changed to `LOGGER.warn(msg, e)`. +- **Renamed `channelId` → `resourceId`** (#10) in MCP tool responses to avoid confusion with Slack channelId. +- **Fixed `deployAgent` typo** (#11) — 'production' listed twice in 4 environment descriptions. +- **Tempered Javadoc** (#17) — now says "currently Slack-only with platform-agnostic model". + +### Deferred (architectural follow-ups) +- **#7** Extensible channel type registry (CDI-based) — for Teams/Discord fork support +- **#9** Prompt injection hardening in `buildFollowUpInput` — truncation + delimiters +- **#13** Replace `ThreadLocal` with explicit parameter passing +- **#15** Lock thread target only after successful conversation start + ## Channel Integration Refactor — Decoupled Multi-Target Architecture (2026-04-18) **Repo:** EDDI (`feature/channel-integrations`) From 39a37a8bfc3fe3ecad1151a1edda21854536ec8f Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 13:32:51 +0200 Subject: [PATCH 10/35] =?UTF-8?q?fix(channels):=20harden=20migration=20too?= =?UTF-8?q?l=20=E2=80=94=20N1-N7=20from=20re-review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N1: Restore per-agent error reporting (was silently swallowed) N2: Detect credential conflicts — when multiple agents share a channelId but have different botToken/signingSecret, skip with action='credential_conflict' and actionable hint N3: Deduplicate target names — agents with identical names get suffixed with short agentId to avoid BadRequestException on duplicate triggers N4: Group key now includes channelType (channelType:channelId) to prevent cross-platform collisions N5: Sort entries by agentId before constructing targets for deterministic defaultTargetName across JVM runs N6: Replace brittle Map with typed MigrationEntry record — eliminates unsafe casts N7: Add shared-reference invariant comment to deepCopyConfig Javadoc --- .../labs/eddi/engine/mcp/McpAdminTools.java | 113 ++++++++++++------ .../channels/ChannelTargetRouter.java | 5 + 2 files changed, 80 insertions(+), 38 deletions(-) diff --git a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java index f80f455b8..716303f56 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java @@ -47,6 +47,8 @@ import java.time.Instant; import java.time.ZoneId; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -1318,9 +1320,14 @@ public String migrateChannelConnectors( var statuses = agentAdmin.getDeploymentStatuses( ai.labs.eddi.engine.model.Deployment.Environment.production); - // Group connectors by platformChannelId to detect duplicates - // channelId → list of (connector, agentId, agentName) - var channelGroups = new LinkedHashMap>>(); + // N6: typed record instead of Map + record MigrationEntry(AgentConfiguration.ChannelConnector connector, + String agentId, String agentName, String channelType) { + } + + // N4: group key includes channelType to avoid cross-platform collisions + var channelGroups = new LinkedHashMap>(); + var skipped = new ArrayList>(); for (var status : statuses) { if (status.getDescriptor() == null || status.getDescriptor().isDeleted()) @@ -1338,75 +1345,105 @@ public String migrateChannelConnectors( String platformChannelId = connector.getConfig().get("channelId"); if (platformChannelId == null || platformChannelId.isBlank()) continue; - - channelGroups.computeIfAbsent(platformChannelId, k -> new ArrayList<>()) - .add(Map.of( - "connector", connector, - "agentId", agentId, - "agentName", status.getDescriptor().getName() != null - ? status.getDescriptor().getName() - : agentId, - "channelType", connector.getType().toString().toLowerCase())); + String channelType = connector.getType().toString().toLowerCase(); + String agentName = status.getDescriptor().getName() != null + ? status.getDescriptor().getName() + : agentId; + + // N4: channelType:channelId as group key + String groupKey = channelType + ":" + platformChannelId; + channelGroups.computeIfAbsent(groupKey, k -> new ArrayList<>()) + .add(new MigrationEntry(connector, agentId, agentName, channelType)); } } catch (Exception e) { - // skip + // N1: restore per-agent error reporting + skipped.add(Map.of("agentId", agentId, + "error", String.valueOf(e.getMessage()))); } } var migrated = new ArrayList>(); - var skipped = new ArrayList>(); - for (var entry : channelGroups.entrySet()) { - String platformChannelId = entry.getKey(); - List> connectors = entry.getValue(); + for (var groupEntry : channelGroups.entrySet()) { + List entries = groupEntry.getValue(); + + // N5: sort by agentId for deterministic ordering + entries.sort(Comparator.comparing(MigrationEntry::agentId)); - // Build a single multi-target config for this channelId - var firstConnector = (AgentConfiguration.ChannelConnector) connectors.get(0).get("connector"); - String channelType = (String) connectors.get(0).get("channelType"); + MigrationEntry first = entries.get(0); + String channelType = first.channelType(); + String platformChannelId = first.connector().getConfig().get("channelId"); + + // N2: detect credential conflicts across merged connectors + var credentialConflicts = new ArrayList(); + for (String credKey : List.of("botToken", "signingSecret")) { + long distinct = entries.stream() + .map(e -> String.valueOf(e.connector().getConfig().get(credKey))) + .distinct().count(); + if (distinct > 1) { + credentialConflicts.add(credKey); + } + } + if (!credentialConflicts.isEmpty()) { + var conflict = new LinkedHashMap(); + conflict.put("platformChannelId", platformChannelId); + conflict.put("channelType", channelType); + conflict.put("action", "credential_conflict"); + conflict.put("conflictingKeys", credentialConflicts); + conflict.put("agents", entries.stream().map(MigrationEntry::agentId).toList()); + conflict.put("hint", "These agents share a channelId but have different " + + String.join("/", credentialConflicts) + + ". Merge manually or ensure all agents use the same Slack app."); + skipped.add(conflict); + continue; + } var newConfig = new ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration(); newConfig.setName(channelType + " — " + platformChannelId); newConfig.setChannelType(channelType); - newConfig.setPlatformConfig(new java.util.HashMap<>(firstConnector.getConfig())); + newConfig.setPlatformConfig(new java.util.HashMap<>(first.connector().getConfig())); var targets = new ArrayList(); - String firstTargetName = null; - - for (var connectorEntry : connectors) { - String agentId = (String) connectorEntry.get("agentId"); - String agentName = (String) connectorEntry.get("agentName"); - var connector = (AgentConfiguration.ChannelConnector) connectorEntry.get("connector"); + var usedNames = new HashSet(); + for (var me : entries) { var target = new ai.labs.eddi.configs.channels.model.ChannelTarget(); - // Use agent name as target name (sanitized) - String targetName = agentName.toLowerCase().replaceAll("[^a-z0-9-]", "-"); + // N3: deduplicate target names by suffixing with short agentId + String baseName = me.agentName().toLowerCase().replaceAll("[^a-z0-9-]", "-"); + String targetName = baseName; + if (!usedNames.add(targetName)) { + // Collision — suffix with short agent ID + String shortId = me.agentId().length() > 6 + ? me.agentId().substring(0, 6) + : me.agentId(); + targetName = baseName + "-" + shortId; + usedNames.add(targetName); + } target.setName(targetName); target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.AGENT); - target.setTargetId(agentId); + target.setTargetId(me.agentId()); - String groupId = connector.getConfig().get("groupId"); + String groupId = me.connector().getConfig().get("groupId"); if (groupId != null && !groupId.isBlank()) { target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.GROUP); target.setTargetId(groupId); } - // Add trigger from target name target.setTriggers(List.of(targetName)); targets.add(target); - if (firstTargetName == null) - firstTargetName = targetName; } newConfig.setTargets(targets); - newConfig.setDefaultTargetName(firstTargetName); + // N5: first in sorted order = deterministic default + newConfig.setDefaultTargetName(targets.get(0).getName()); var resultEntry = new LinkedHashMap(); resultEntry.put("platformChannelId", platformChannelId); resultEntry.put("channelType", channelType); resultEntry.put("targetCount", targets.size()); - if (connectors.size() > 1) { - resultEntry.put("mergedAgents", connectors.stream() - .map(c -> (String) c.get("agentId")).toList()); + if (entries.size() > 1) { + resultEntry.put("mergedAgents", entries.stream() + .map(MigrationEntry::agentId).toList()); } if (isDryRun) { diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index 98fbefbd5..e338f82fe 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -386,6 +386,11 @@ private void refreshInternal() { * Deep-copy a config so that secret resolution does not mutate the store's * cached instance (which must retain {@code ${eddivault:...}} references for * the REST API). + *

+ * Invariant: {@code ChannelTarget} instances are shared by reference + * between the copy and the original. The router must never mutate target + * objects — they are read-only after construction. If a future change needs + * per-target secret resolution, targets must be deep-copied too. */ private ChannelIntegrationConfiguration deepCopyConfig(ChannelIntegrationConfiguration src) { var copy = new ChannelIntegrationConfiguration(); From 63050b813faa6e0ba580853693eb3e68c07d8ad0 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 13:33:24 +0200 Subject: [PATCH 11/35] docs(channels): add migration tool hardening changelog entry (N1-N7) --- docs/changelog.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 37fdbfa5a..f01196319 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,6 +13,20 @@ Each entry follows this format: - **Decision** — Key design decisions and their reasoning - **Files** — Links to modified files +## Channel Integration — Migration Tool Hardening (2026-04-18) + +**Repo:** EDDI (`feature/channel-integrations`) + +**What changed:** Re-review of the migration rewrite (fix #2) found 7 new issues (N1-N7). All fixed. + +- **N1: Restored per-agent error reporting** — regression from rewrite silently swallowed agent read failures. +- **N2: Credential conflict detection** — when multiple agents share a channelId with different botToken/signingSecret, migration now skips with `action: "credential_conflict"` and an actionable hint. +- **N3: Target name deduplication** — agents with identical names in the same channel get suffixed with short agentId to avoid `BadRequestException` on duplicate triggers. +- **N4: Group key includes channelType** — prevents cross-platform collisions (`channelType:channelId`). +- **N5: Deterministic ordering** — entries sorted by agentId before constructing targets; `defaultTargetName` is now reproducible across JVM runs. +- **N6: Typed `MigrationEntry` record** — replaces `Map` with unsafe casts. +- **N7: `deepCopyConfig` invariant comment** — documents that target instances are shared by reference and must not be mutated. + ## Channel Integration — Code Review Hardening (2026-04-18) **Repo:** EDDI (`feature/channel-integrations`) From 6abb1f7805019081a2099e5ea5217eec369f3245 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 13:48:51 +0200 Subject: [PATCH 12/35] test(channels): add 19 tests + polish migration edge cases New tests: - RestChannelIntegrationStoreValidationTest (17 tests) Covers: name, channelType, targets, defaultTarget, trigger uniqueness, null/blank triggers, observeMode rejection - ChannelTargetRouterTest: 2 new edge case tests - 'help:' with colon is NOT a help signal (#4 from review) - 'architect:' with empty remainder matches trigger correctly Migration polish: - N3 counter fallback: dedup loop handles triple+ name collisions (extremely unlikely but now provably correct) - N2 comment: documents extending credential key list when Teams/Discord adapters arrive Validation visibility: - validateConfiguration() changed from private to package-private for direct unit testing Total channel integration tests: 42 (was 23) --- .../rest/RestChannelIntegrationStore.java | 3 +- .../labs/eddi/engine/mcp/McpAdminTools.java | 13 +- ...ChannelIntegrationStoreValidationTest.java | 284 ++++++++++++++++++ .../channels/ChannelTargetRouterTest.java | 23 ++ 4 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java diff --git a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java index 7252bde64..27fe18db2 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java @@ -118,7 +118,8 @@ public IResourceStore.IResourceId getCurrentResourceId(String id) // ─── Validation ──────────────────────────────────────────────────────────── - private void validateConfiguration(ChannelIntegrationConfiguration config) { + // Visible for testing + void validateConfiguration(ChannelIntegrationConfiguration config) { if (config.getName() == null || config.getName().isBlank()) { throw new BadRequestException("Channel integration name is required."); } diff --git a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java index 716303f56..db0738ea5 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java @@ -1374,7 +1374,9 @@ record MigrationEntry(AgentConfiguration.ChannelConnector connector, String channelType = first.channelType(); String platformChannelId = first.connector().getConfig().get("channelId"); - // N2: detect credential conflicts across merged connectors + // N2: detect credential conflicts across merged connectors. + // Currently checks Slack keys only; extend this list when + // Teams/Discord adapters arrive (e.g. appPassword, serviceUrl). var credentialConflicts = new ArrayList(); for (String credKey : List.of("botToken", "signingSecret")) { long distinct = entries.stream() @@ -1408,7 +1410,7 @@ record MigrationEntry(AgentConfiguration.ChannelConnector connector, for (var me : entries) { var target = new ai.labs.eddi.configs.channels.model.ChannelTarget(); - // N3: deduplicate target names by suffixing with short agentId + // N3: deduplicate target names with counter fallback String baseName = me.agentName().toLowerCase().replaceAll("[^a-z0-9-]", "-"); String targetName = baseName; if (!usedNames.add(targetName)) { @@ -1417,7 +1419,12 @@ record MigrationEntry(AgentConfiguration.ChannelConnector connector, ? me.agentId().substring(0, 6) : me.agentId(); targetName = baseName + "-" + shortId; - usedNames.add(targetName); + // Counter fallback for the (extremely unlikely) case where + // two agent IDs share the same 6-char prefix + int counter = 2; + while (!usedNames.add(targetName)) { + targetName = baseName + "-" + shortId + "-" + counter++; + } } target.setName(targetName); target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.AGENT); diff --git a/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java new file mode 100644 index 000000000..e3a0940d5 --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java @@ -0,0 +1,284 @@ +package ai.labs.eddi.configs.channels.rest; + +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.configs.channels.model.ChannelTarget; +import jakarta.ws.rs.BadRequestException; +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 java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link RestChannelIntegrationStore#validateConfiguration}. + * Covers all validation rules: name, channelType, targets, defaultTarget, + * trigger uniqueness, null/blank triggers, and observeMode rejection. + */ +class RestChannelIntegrationStoreValidationTest { + + private RestChannelIntegrationStore store; + private ChannelIntegrationConfiguration config; + + @BeforeEach + void setUp() { + // Construct with null dependencies — we only call validateConfiguration() + store = new RestChannelIntegrationStore(null, null); + config = validConfig(); + } + + /** + * Produces a minimal valid config so tests can mutate one field at a time. + */ + private static ChannelIntegrationConfiguration validConfig() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setName("My Slack Hub"); + cfg.setChannelType("slack"); + cfg.setDefaultTargetName("support"); + + var target = new ChannelTarget(); + target.setName("support"); + target.setTargetId("agent-abc"); + target.setType(ChannelTarget.TargetType.AGENT); + target.setTriggers(List.of("support")); + + cfg.setTargets(List.of(target)); + return cfg; + } + + // ─── Happy path ──────────────────────────────────────────────────────────── + + @Test + @DisplayName("valid config passes validation without exception") + void validConfigPasses() { + assertDoesNotThrow(() -> store.validateConfiguration(config)); + } + + // ─── Name ────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Name validation") + class NameValidation { + + @Test + @DisplayName("null name → BadRequest") + void nullName() { + config.setName(null); + var ex = assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().contains("name")); + } + + @Test + @DisplayName("blank name → BadRequest") + void blankName() { + config.setName(" "); + assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + } + } + + // ─── Channel type ────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Channel type validation") + class ChannelTypeValidation { + + @Test + @DisplayName("null channelType → BadRequest") + void nullChannelType() { + config.setChannelType(null); + assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + } + + @Test + @DisplayName("unknown channelType → BadRequest with registered types") + void unknownChannelType() { + config.setChannelType("telegram"); + var ex = assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().contains("telegram")); + assertTrue(ex.getMessage().contains("Registered types")); + } + + @Test + @DisplayName("'slack' is accepted") + void slackAccepted() { + config.setChannelType("slack"); + assertDoesNotThrow(() -> store.validateConfiguration(config)); + } + } + + // ─── Targets ─────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Target validation") + class TargetValidation { + + @Test + @DisplayName("null targets → BadRequest") + void nullTargets() { + config.setTargets(null); + assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + } + + @Test + @DisplayName("empty targets → BadRequest") + void emptyTargets() { + config.setTargets(List.of()); + assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + } + + @Test + @DisplayName("target with null name → BadRequest") + void targetNullName() { + var target = new ChannelTarget(); + target.setName(null); + target.setTargetId("agent-x"); + target.setTriggers(List.of("x")); + config.setTargets(List.of(target)); + config.setDefaultTargetName("x"); // will fail before default check + + assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + } + + @Test + @DisplayName("target with null targetId → BadRequest") + void targetNullTargetId() { + var target = new ChannelTarget(); + target.setName("support"); + target.setTargetId(null); + target.setTriggers(List.of("support")); + config.setTargets(List.of(target)); + + var ex = assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().contains("targetId")); + } + } + + // ─── Default target ──────────────────────────────────────────────────────── + + @Nested + @DisplayName("Default target validation") + class DefaultTargetValidation { + + @Test + @DisplayName("null defaultTargetName → BadRequest") + void nullDefaultTarget() { + config.setDefaultTargetName(null); + assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + } + + @Test + @DisplayName("defaultTargetName not matching any target → BadRequest") + void defaultTargetMismatch() { + config.setDefaultTargetName("nonexistent"); + var ex = assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().contains("nonexistent")); + } + } + + // ─── Trigger validation ──────────────────────────────────────────────────── + + @Nested + @DisplayName("Trigger validation") + class TriggerValidation { + + @Test + @DisplayName("duplicate trigger across targets → BadRequest") + void duplicateTrigger() { + var t1 = new ChannelTarget(); + t1.setName("alpha"); + t1.setTargetId("agent-1"); + t1.setTriggers(List.of("support")); + + var t2 = new ChannelTarget(); + t2.setName("beta"); + t2.setTargetId("agent-2"); + t2.setTriggers(List.of("SUPPORT")); // case-insensitive dup + + config.setTargets(List.of(t1, t2)); + config.setDefaultTargetName("alpha"); + + var ex = assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().toLowerCase().contains("duplicate")); + } + + @Test + @DisplayName("null trigger in list → BadRequest") + void nullTrigger() { + var triggers = new ArrayList(); + triggers.add("support"); + triggers.add(null); + + var target = new ChannelTarget(); + target.setName("support"); + target.setTargetId("agent-abc"); + target.setTriggers(triggers); + config.setTargets(List.of(target)); + + var ex = assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().contains("null or blank")); + } + + @Test + @DisplayName("blank trigger in list → BadRequest") + void blankTrigger() { + var target = new ChannelTarget(); + target.setName("support"); + target.setTargetId("agent-abc"); + target.setTriggers(List.of("support", " ")); + config.setTargets(List.of(target)); + + assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + } + } + + // ─── Observe mode ────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Observe mode validation") + class ObserveModeValidation { + + @Test + @DisplayName("observeMode=true → BadRequest (not yet implemented)") + void observeModeRejected() { + var target = new ChannelTarget(); + target.setName("support"); + target.setTargetId("agent-abc"); + target.setTriggers(List.of("support")); + target.setObserveMode(true); + config.setTargets(List.of(target)); + + var ex = assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().contains("observeMode")); + } + + @Test + @DisplayName("observeMode=false → passes") + void observeModeFalse() { + var target = new ChannelTarget(); + target.setName("support"); + target.setTargetId("agent-abc"); + target.setTriggers(List.of("support")); + target.setObserveMode(false); + config.setTargets(List.of(target)); + + assertDoesNotThrow(() -> store.validateConfiguration(config)); + } + } +} diff --git a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java index 5eecda509..d51290a0c 100644 --- a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java @@ -240,6 +240,29 @@ void nullMessageReturnsNull() { void blankMessageReturnsNull() { assertNull(router.resolveFromIntegration(integration, " ")); } + + @Test + @DisplayName("help: (with colon) → NOT help signal, falls to default with full message") + void helpWithColonIsNotHelpSignal() { + // "help:" has a colon — "help" is the candidate trigger. Since "help" is not + // a configured trigger, it falls through to the default target with the full + // message preserved (including the colon). + ResolvedTarget result = router.resolveFromIntegration(integration, "help:"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + assertEquals("help:", result.strippedMessage()); + } + + @Test + @DisplayName("architect: (empty after colon) → matches trigger, empty stripped message") + void triggerWithEmptyRemainder() { + ResolvedTarget result = router.resolveFromIntegration(integration, "architect:"); + + assertNotNull(result); + assertEquals("architect", result.target().getName()); + assertEquals("", result.strippedMessage()); + } } // ─── Thread target locking ───────────────────────────────────────────────── From 1925a010843b454c55938daf3e07c6228195ece8 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 22:25:33 +0200 Subject: [PATCH 13/35] refactor(channels): replace MCP migration with startup migration, deprecate legacy channels - Remove migrate_channel_connectors MCP tool from McpAdminTools - Add ChannelConnectorMigration: startup one-shot migration following V6RenameMigration pattern (flag-based, idempotent, retry-safe) - Wire into AgentDeploymentManagement.autoDeployAgents() startup sequence - Deprecate ChannelConnector class and channels field in AgentConfiguration with @Deprecated(since=6.1.0, forRemoval=true) Migration runs once at startup, sets flag in migrationlog collection. Legacy channel configs are auto-migrated to standalone ChannelIntegrationConfiguration documents. --- .../agents/model/AgentConfiguration.java | 15 ++ .../migration/ChannelConnectorMigration.java | 176 +++++++++++++++++ .../labs/eddi/engine/mcp/McpAdminTools.java | 184 ------------------ .../internal/AgentDeploymentManagement.java | 7 +- 4 files changed, 197 insertions(+), 185 deletions(-) create mode 100644 src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java diff --git a/src/main/java/ai/labs/eddi/configs/agents/model/AgentConfiguration.java b/src/main/java/ai/labs/eddi/configs/agents/model/AgentConfiguration.java index d3658c683..a17f19329 100644 --- a/src/main/java/ai/labs/eddi/configs/agents/model/AgentConfiguration.java +++ b/src/main/java/ai/labs/eddi/configs/agents/model/AgentConfiguration.java @@ -15,6 +15,14 @@ public class AgentConfiguration { @JsonAlias("packages") private List workflows = new ArrayList<>(); + /** + * @deprecated Since 6.1.0. Use standalone + * {@code ChannelIntegrationConfiguration} documents instead. Legacy + * connectors are auto-migrated at startup by + * {@code ChannelConnectorMigration}. This field will be removed in + * a future release. + */ + @Deprecated(since = "6.1.0", forRemoval = true) private List channels = new ArrayList<>(); /** @@ -66,6 +74,11 @@ public class AgentConfiguration { */ private MemoryPolicy memoryPolicy; + /** + * @deprecated Since 6.1.0. Replaced by {@code ChannelIntegrationConfiguration} + * with multi-target routing support. + */ + @Deprecated(since = "6.1.0", forRemoval = true) public static class ChannelConnector { private URI type; private Map config = new HashMap<>(); @@ -95,10 +108,12 @@ public void setWorkflows(List workflows) { this.workflows = workflows; } + @Deprecated(since = "6.1.0", forRemoval = true) public List getChannels() { return channels; } + @Deprecated(since = "6.1.0", forRemoval = true) public void setChannels(List channels) { this.channels = channels; } diff --git a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java new file mode 100644 index 000000000..052f606df --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java @@ -0,0 +1,176 @@ +package ai.labs.eddi.configs.migration; + +import ai.labs.eddi.configs.agents.IAgentStore; +import ai.labs.eddi.configs.agents.model.AgentConfiguration; +import ai.labs.eddi.configs.agents.model.AgentConfiguration.ChannelConnector; +import ai.labs.eddi.configs.channels.IChannelIntegrationStore; +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.configs.channels.model.ChannelTarget; +import ai.labs.eddi.configs.deployment.IDeploymentStore; +import ai.labs.eddi.configs.migration.model.MigrationLog; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.util.*; + +import static ai.labs.eddi.configs.deployment.model.DeploymentInfo.DeploymentStatus.deployed; + +/** + * One-shot startup migration: converts legacy {@link ChannelConnector} entries + * embedded in {@link AgentConfiguration#getChannels()} into standalone + * {@link ChannelIntegrationConfiguration} documents. + *

+ * Follows the same flag-based pattern as {@link V6RenameMigration}: checks a + * {@code MigrationLogStore} flag on startup and runs only once. + *

+ * Since Slack channel support was introduced as a preview feature with very few + * users, this migration is intentionally simple. It creates one + * {@code ChannelIntegrationConfiguration} per unique {@code channelId}, merging + * multiple agents targeting the same channel into a multi-target config. + * + * @since 6.1.0 + */ +@ApplicationScoped +public class ChannelConnectorMigration { + + private static final Logger LOGGER = Logger.getLogger(ChannelConnectorMigration.class); + private static final String MIGRATION_KEY = "channel-connector-migration-complete"; + + private final IDeploymentStore deploymentStore; + private final IAgentStore agentStore; + private final IChannelIntegrationStore channelStore; + private final MigrationLogStore migrationLogStore; + + @Inject + public ChannelConnectorMigration(IDeploymentStore deploymentStore, + IAgentStore agentStore, + IChannelIntegrationStore channelStore, + MigrationLogStore migrationLogStore) { + this.deploymentStore = deploymentStore; + this.agentStore = agentStore; + this.channelStore = channelStore; + this.migrationLogStore = migrationLogStore; + } + + /** + * Run the channel connector migration if not already applied. Called from + * {@code AgentDeploymentManagement.autoDeployAgents()}. + */ + public void runIfNeeded() { + if (migrationLogStore.readMigrationLog(MIGRATION_KEY) != null) { + LOGGER.debug("Channel connector migration already applied — skipping"); + return; + } + + LOGGER.info("Starting channel connector migration..."); + + try { + int migrated = migrateConnectors(); + LOGGER.infof("Channel connector migration complete: %d configs created", migrated); + } catch (Exception e) { + LOGGER.error("Channel connector migration failed — will retry on next startup", e); + return; // Don't set flag so it retries + } + + migrationLogStore.createMigrationLog(new MigrationLog(MIGRATION_KEY)); + } + + private int migrateConnectors() { + // Group connectors by channelType:channelId + var channelGroups = new LinkedHashMap>(); + + try { + var statuses = deploymentStore.readDeploymentInfos(deployed); + for (var status : statuses) { + if (status.getAgentId() == null || status.getAgentVersion() == null) { + continue; + } + String agentId = status.getAgentId(); + try { + var agentConfig = agentStore.readAgent(agentId, status.getAgentVersion()); + if (agentConfig == null || agentConfig.getChannels() == null) { + continue; + } + for (var connector : agentConfig.getChannels()) { + if (connector.getType() == null || connector.getConfig() == null) { + continue; + } + String channelId = connector.getConfig().get("channelId"); + if (channelId == null || channelId.isBlank()) { + continue; + } + String channelType = connector.getType().toString().toLowerCase(); + String groupKey = channelType + ":" + channelId; + channelGroups.computeIfAbsent(groupKey, k -> new ArrayList<>()) + .add(new ConnectorEntry(connector, agentId, channelType)); + } + } catch (Exception e) { + LOGGER.warnf("Skipping agent %s during channel migration: %s", agentId, e.getMessage()); + } + } + } catch (Exception e) { + LOGGER.warn("Failed to read deployment infos for channel migration", e); + throw new RuntimeException("Cannot read deployment infos", e); + } + + int created = 0; + for (var entry : channelGroups.entrySet()) { + var entries = entry.getValue(); + // Sort for deterministic default target + entries.sort(Comparator.comparing(ConnectorEntry::agentId)); + + var first = entries.get(0); + String channelId = first.connector().getConfig().get("channelId"); + String channelType = first.channelType(); + + var config = new ChannelIntegrationConfiguration(); + config.setName(channelType + " — " + channelId); + config.setChannelType(channelType); + config.setPlatformConfig(new HashMap<>(first.connector().getConfig())); + + var targets = new ArrayList(); + var usedNames = new HashSet(); + + for (var ce : entries) { + var target = new ChannelTarget(); + String baseName = ce.agentId().toLowerCase().replaceAll("[^a-z0-9-]", "-"); + String targetName = baseName; + if (!usedNames.add(targetName)) { + int counter = 2; + while (!usedNames.add(targetName)) { + targetName = baseName + "-" + counter++; + } + } + target.setName(targetName); + target.setType(ChannelTarget.TargetType.AGENT); + target.setTargetId(ce.agentId()); + + String groupId = ce.connector().getConfig().get("groupId"); + if (groupId != null && !groupId.isBlank()) { + target.setType(ChannelTarget.TargetType.GROUP); + target.setTargetId(groupId); + } + + target.setTriggers(List.of(targetName)); + targets.add(target); + } + + config.setTargets(targets); + config.setDefaultTargetName(targets.get(0).getName()); + + try { + channelStore.create(config); + created++; + LOGGER.infof(" Migrated channel %s:%s (%d targets)", channelType, channelId, targets.size()); + } catch (Exception e) { + LOGGER.warnf(" Failed to create config for %s:%s — %s", channelType, channelId, e.getMessage()); + } + } + + return created; + } + + private record ConnectorEntry(ChannelConnector connector, String agentId, String channelType) { + } +} diff --git a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java index db0738ea5..61290f5eb 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java @@ -47,8 +47,6 @@ import java.time.Instant; import java.time.ZoneId; import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -1303,186 +1301,4 @@ public String deleteChannelIntegration( return errorJson("Failed to delete channel integration: " + e.getMessage()); } } - - @Tool(name = "migrate_channel_connectors", description = "Migrate legacy ChannelConnector entries from agent configs " - + "to standalone ChannelIntegrationConfigurations. Scans all deployed agents and merges entries for the same " - + "channelId into a single multi-target config. Non-destructive (does not modify agent configs). " - + "Run this once to upgrade from the old channel model.") - public String migrateChannelConnectors( - @ToolArg(description = "Dry run mode — show what would be created without creating (default: true)") Boolean dryRun) { - requireRole(identity, authEnabled, "eddi-admin"); - try { - boolean isDryRun = dryRun == null || dryRun; - var localAgentStore = getRestStore(IRestAgentStore.class); - var channelStore = getRestStore( - ai.labs.eddi.configs.channels.IRestChannelIntegrationStore.class); - - var statuses = agentAdmin.getDeploymentStatuses( - ai.labs.eddi.engine.model.Deployment.Environment.production); - - // N6: typed record instead of Map - record MigrationEntry(AgentConfiguration.ChannelConnector connector, - String agentId, String agentName, String channelType) { - } - - // N4: group key includes channelType to avoid cross-platform collisions - var channelGroups = new LinkedHashMap>(); - var skipped = new ArrayList>(); - - for (var status : statuses) { - if (status.getDescriptor() == null || status.getDescriptor().isDeleted()) - continue; - String agentId = status.getAgentId(); - try { - AgentConfiguration agentConfig = localAgentStore.readAgent( - agentId, status.getAgentVersion()); - if (agentConfig == null || agentConfig.getChannels() == null) - continue; - - for (var connector : agentConfig.getChannels()) { - if (connector.getType() == null || connector.getConfig() == null) - continue; - String platformChannelId = connector.getConfig().get("channelId"); - if (platformChannelId == null || platformChannelId.isBlank()) - continue; - String channelType = connector.getType().toString().toLowerCase(); - String agentName = status.getDescriptor().getName() != null - ? status.getDescriptor().getName() - : agentId; - - // N4: channelType:channelId as group key - String groupKey = channelType + ":" + platformChannelId; - channelGroups.computeIfAbsent(groupKey, k -> new ArrayList<>()) - .add(new MigrationEntry(connector, agentId, agentName, channelType)); - } - } catch (Exception e) { - // N1: restore per-agent error reporting - skipped.add(Map.of("agentId", agentId, - "error", String.valueOf(e.getMessage()))); - } - } - - var migrated = new ArrayList>(); - - for (var groupEntry : channelGroups.entrySet()) { - List entries = groupEntry.getValue(); - - // N5: sort by agentId for deterministic ordering - entries.sort(Comparator.comparing(MigrationEntry::agentId)); - - MigrationEntry first = entries.get(0); - String channelType = first.channelType(); - String platformChannelId = first.connector().getConfig().get("channelId"); - - // N2: detect credential conflicts across merged connectors. - // Currently checks Slack keys only; extend this list when - // Teams/Discord adapters arrive (e.g. appPassword, serviceUrl). - var credentialConflicts = new ArrayList(); - for (String credKey : List.of("botToken", "signingSecret")) { - long distinct = entries.stream() - .map(e -> String.valueOf(e.connector().getConfig().get(credKey))) - .distinct().count(); - if (distinct > 1) { - credentialConflicts.add(credKey); - } - } - if (!credentialConflicts.isEmpty()) { - var conflict = new LinkedHashMap(); - conflict.put("platformChannelId", platformChannelId); - conflict.put("channelType", channelType); - conflict.put("action", "credential_conflict"); - conflict.put("conflictingKeys", credentialConflicts); - conflict.put("agents", entries.stream().map(MigrationEntry::agentId).toList()); - conflict.put("hint", "These agents share a channelId but have different " - + String.join("/", credentialConflicts) - + ". Merge manually or ensure all agents use the same Slack app."); - skipped.add(conflict); - continue; - } - - var newConfig = new ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration(); - newConfig.setName(channelType + " — " + platformChannelId); - newConfig.setChannelType(channelType); - newConfig.setPlatformConfig(new java.util.HashMap<>(first.connector().getConfig())); - - var targets = new ArrayList(); - var usedNames = new HashSet(); - - for (var me : entries) { - var target = new ai.labs.eddi.configs.channels.model.ChannelTarget(); - // N3: deduplicate target names with counter fallback - String baseName = me.agentName().toLowerCase().replaceAll("[^a-z0-9-]", "-"); - String targetName = baseName; - if (!usedNames.add(targetName)) { - // Collision — suffix with short agent ID - String shortId = me.agentId().length() > 6 - ? me.agentId().substring(0, 6) - : me.agentId(); - targetName = baseName + "-" + shortId; - // Counter fallback for the (extremely unlikely) case where - // two agent IDs share the same 6-char prefix - int counter = 2; - while (!usedNames.add(targetName)) { - targetName = baseName + "-" + shortId + "-" + counter++; - } - } - target.setName(targetName); - target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.AGENT); - target.setTargetId(me.agentId()); - - String groupId = me.connector().getConfig().get("groupId"); - if (groupId != null && !groupId.isBlank()) { - target.setType(ai.labs.eddi.configs.channels.model.ChannelTarget.TargetType.GROUP); - target.setTargetId(groupId); - } - - target.setTriggers(List.of(targetName)); - targets.add(target); - } - - newConfig.setTargets(targets); - // N5: first in sorted order = deterministic default - newConfig.setDefaultTargetName(targets.get(0).getName()); - - var resultEntry = new LinkedHashMap(); - resultEntry.put("platformChannelId", platformChannelId); - resultEntry.put("channelType", channelType); - resultEntry.put("targetCount", targets.size()); - if (entries.size() > 1) { - resultEntry.put("mergedAgents", entries.stream() - .map(MigrationEntry::agentId).toList()); - } - - if (isDryRun) { - resultEntry.put("action", "would_create"); - resultEntry.put("config", newConfig); - migrated.add(resultEntry); - } else { - try { - Response response = channelStore.createChannel(newConfig); - String location = response.getHeaderString("Location"); - resultEntry.put("action", "created"); - resultEntry.put("location", location); - migrated.add(resultEntry); - } catch (Exception createErr) { - resultEntry.put("action", "failed"); - resultEntry.put("error", createErr.getMessage()); - skipped.add(resultEntry); - } - } - } - - var result = new LinkedHashMap(); - result.put("dryRun", isDryRun); - result.put("migratedCount", migrated.size()); - result.put("skippedCount", skipped.size()); - result.put("migrated", migrated); - if (!skipped.isEmpty()) - result.put("skipped", skipped); - return resultJson("migration_complete", result); - } catch (Exception e) { - LOGGER.error("MCP migrate_channel_connectors failed", e); - return errorJson("Failed to migrate channel connectors: " + e.getMessage()); - } - } } diff --git a/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java b/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java index 6bbd52fc9..c78193154 100644 --- a/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java +++ b/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java @@ -4,6 +4,7 @@ import ai.labs.eddi.configs.deployment.IDeploymentStore; import ai.labs.eddi.configs.deployment.model.DeploymentInfo; import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; +import ai.labs.eddi.configs.migration.ChannelConnectorMigration; import ai.labs.eddi.configs.migration.IMigrationManager; import ai.labs.eddi.configs.migration.V6QuteMigration; import ai.labs.eddi.configs.migration.V6RenameMigration; @@ -58,6 +59,7 @@ public class AgentDeploymentManagement implements IAgentDeploymentManagement { private final IMigrationManager migrationManager; private final V6RenameMigration v6RenameMigration; private final V6QuteMigration v6QuteMigration; + private final ChannelConnectorMigration channelConnectorMigration; private final IAgentsReadiness agentsReadiness; private final IRuntime runtime; private final int maximumLifeTimeOfIdleConversationsInDays; @@ -68,7 +70,8 @@ public class AgentDeploymentManagement implements IAgentDeploymentManagement { @Inject public AgentDeploymentManagement(IDeploymentStore deploymentStore, IAgentFactory agentFactory, IAgentStore agentStore, IAgentsReadiness agentsReadiness, IConversationMemoryStore conversationMemoryStore, IDocumentDescriptorStore documentDescriptorStore, - IMigrationManager migrationManager, V6RenameMigration v6RenameMigration, V6QuteMigration v6QuteMigration, IRuntime runtime, + IMigrationManager migrationManager, V6RenameMigration v6RenameMigration, V6QuteMigration v6QuteMigration, + ChannelConnectorMigration channelConnectorMigration, IRuntime runtime, @ConfigProperty(name = "eddi.conversations.maximumLifeTimeOfIdleConversationsInDays") int maximumLifeTimeOfIdleConversationsInDays) { this.deploymentStore = deploymentStore; this.agentFactory = agentFactory; @@ -79,6 +82,7 @@ public AgentDeploymentManagement(IDeploymentStore deploymentStore, IAgentFactory this.migrationManager = migrationManager; this.v6RenameMigration = v6RenameMigration; this.v6QuteMigration = v6QuteMigration; + this.channelConnectorMigration = channelConnectorMigration; this.runtime = runtime; this.maximumLifeTimeOfIdleConversationsInDays = maximumLifeTimeOfIdleConversationsInDays; } @@ -98,6 +102,7 @@ public void autoDeployAgents() { // V6 rename migration must run before document-level migrations v6RenameMigration.runIfNeeded(); v6QuteMigration.runIfNeeded(); + channelConnectorMigration.runIfNeeded(); migrationManager.startMigrationIfFirstTimeRun(() -> { checkDeployments(); From 1200ebd4b55c638e0524df3f12e0954bf4fb9f55 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 22:56:08 +0200 Subject: [PATCH 14/35] docs(channels): add startup migration & deprecation changelog entry --- docs/changelog.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index f01196319..954e20904 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,6 +13,31 @@ Each entry follows this format: - **Decision** — Key design decisions and their reasoning - **Files** — Links to modified files +## Channel Integration — Startup Migration & Legacy Deprecation (2026-04-18) + +**Repo:** EDDI (`feature/channel-integrations`) + +**What changed:** Replaced the MCP-based migration tool with a deterministic startup migration and deprecated legacy channel connectors. + +**Key changes:** +- **Removed** `migrate_channel_connectors` MCP tool from `McpAdminTools` — migration is now infrastructure, not an admin tool +- **Added** `ChannelConnectorMigration` — startup one-shot migration following the established `V6RenameMigration` pattern (flag-based via `migrationlog` collection, idempotent, retry-safe on failure) +- **Wired** into `AgentDeploymentManagement.autoDeployAgents()` after V6 migrations, before agent deployment +- **Deprecated** `ChannelConnector` class and `channels` field in `AgentConfiguration` with `@Deprecated(since="6.1.0", forRemoval=true)` + +**Design decisions:** +- Startup migration is cleaner than on-demand MCP tool: runs exactly once, no admin intervention needed, follows existing patterns +- Deprecation rather than removal: old JSON configs in MongoDB can still deserialize; the legacy fallback in `ChannelTargetRouter` remains as a safety net +- Migration is deliberately simple (preview feature with very few users) + +**Files:** +- `ChannelConnectorMigration.java` [NEW] — startup migration +- `McpAdminTools.java` — removed migration tool (-184 lines) +- `AgentDeploymentManagement.java` — wired migration into startup +- `AgentConfiguration.java` — deprecated channels field + ChannelConnector class + +--- + ## Channel Integration — Migration Tool Hardening (2026-04-18) **Repo:** EDDI (`feature/channel-integrations`) From aa0d1bf8a976536631b8b8ef39f61fcddd9d9c27 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 18 Apr 2026 23:53:34 +0200 Subject: [PATCH 15/35] =?UTF-8?q?fix(channels):=20readAgent=E2=86=92read?= =?UTF-8?q?=20on=20IAgentStore,=20signing=20secret=20from=20resolved=20cop?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1: ChannelConnectorMigration called readAgent() which only exists on IRestAgentStore. IAgentStore (via IResourceStore) uses read(). Caught by clean compile after incremental build masked the error. R2: ChannelTargetRouter collected signing secrets from the original config (with vault references like \) instead of the deep-copied config with resolved secrets. HMAC verification requires the actual secret value. --- .../labs/eddi/configs/migration/ChannelConnectorMigration.java | 2 +- .../ai/labs/eddi/integrations/channels/ChannelTargetRouter.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java index 052f606df..a96504f63 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java @@ -88,7 +88,7 @@ private int migrateConnectors() { } String agentId = status.getAgentId(); try { - var agentConfig = agentStore.readAgent(agentId, status.getAgentVersion()); + var agentConfig = agentStore.read(agentId, status.getAgentVersion()); if (agentConfig == null || agentConfig.getChannels() == null) { continue; } diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index e338f82fe..8c08ea8bd 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -310,7 +310,7 @@ private void refreshInternal() { // Collect signing secrets for Slack if (CHANNEL_TYPE_SLACK.equals( config.getChannelType().toLowerCase())) { - String ss = config.getPlatformConfig().get("signingSecret"); + String ss = copy.getPlatformConfig().get("signingSecret"); if (ss != null && !ss.isBlank()) { newSigningSecrets.add(ss); } From c861cb07d2fc2bb23faf3355c802aba68168d7bd Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 19 Apr 2026 00:46:05 +0200 Subject: [PATCH 16/35] test(channels): add 31 tests for refresh, public API, secrets, legacy fallback New test class: ChannelTargetRouterRefreshTest (31 tests) - resolveTarget with new-style integration (5 tests) Covers: trigger match via public API, default target, unknown channel, bot token vault resolution, signing secret vault resolution - Legacy fallback (3 tests) Covers: legacy agent routing, group type routing, new-style suppresses legacy - Signing secrets (3 tests) Covers: resolved from new-style, includes legacy, empty for non-slack - Channel detection (5 tests) Covers: hasAnyChannels (new-style, legacy, cross-type), getIntegration - Deep copy safety (2 tests) Covers: store original unchanged, returned config has resolved secrets - Refresh mechanism (5 tests) Covers: first-call load, no re-refresh within interval, store exception, null channelId, null config - Secret resolution (2 tests) Covers: absent secrets, resolver failure - ResolvedTarget accessors (4 tests) Covers: legacy fallback, integration preference, both null - LegacyTarget conversion (2 tests) Covers: with/without groupId Total channel integration tests: 73 (was 42) --- .../ChannelTargetRouterRefreshTest.java | 654 ++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java diff --git a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java new file mode 100644 index 000000000..88489cf61 --- /dev/null +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java @@ -0,0 +1,654 @@ +package ai.labs.eddi.integrations.channels; + +import ai.labs.eddi.configs.agents.IRestAgentStore; +import ai.labs.eddi.configs.agents.model.AgentConfiguration; +import ai.labs.eddi.configs.agents.model.AgentConfiguration.ChannelConnector; +import ai.labs.eddi.configs.channels.IChannelIntegrationStore; +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.configs.channels.model.ChannelTarget; +import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; +import ai.labs.eddi.configs.descriptors.model.DocumentDescriptor; +import ai.labs.eddi.engine.api.IRestAgentAdministration; +import ai.labs.eddi.engine.caching.ICache; +import ai.labs.eddi.engine.caching.ICacheFactory; +import ai.labs.eddi.engine.model.AgentDeploymentStatus; +import ai.labs.eddi.engine.model.Deployment; +import ai.labs.eddi.integrations.channels.ChannelTargetRouter.ResolvedTarget; +import ai.labs.eddi.secrets.SecretResolver; +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 java.net.URI; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link ChannelTargetRouter} covering the full public API: refresh + * logic, secret resolution, legacy fallback, deep copy safety, and channel + * detection. + *

+ * Complements {@link ChannelTargetRouterTest} which covers trigger matching. + */ +class ChannelTargetRouterRefreshTest { + + // A valid hex ID for extractResourceId (must be ≥18 hex chars) + private static final String CHANNEL_CONFIG_ID = "5262b802dc6c4008b54c"; + private static final String AGENT_ID = "a1b2c3d4e5f6a7b8c9d0"; + private static final String CHANNEL_ID = "C07TESTCHANNEL"; + + private IChannelIntegrationStore channelStore; + private IDocumentDescriptorStore descriptorStore; + private IRestAgentAdministration agentAdmin; + private IRestAgentStore agentStore; + private SecretResolver secretResolver; + private ChannelTargetRouter router; + + @BeforeEach + void setUp() throws Exception { + channelStore = mock(IChannelIntegrationStore.class); + descriptorStore = mock(IDocumentDescriptorStore.class); + agentAdmin = mock(IRestAgentAdministration.class); + agentStore = mock(IRestAgentStore.class); + secretResolver = mock(SecretResolver.class); + + ICacheFactory cacheFactory = mock(ICacheFactory.class); + when(cacheFactory.getCache(eq("channel-thread-locks"), any(Duration.class))) + .thenReturn(new MapCache<>()); + + router = new ChannelTargetRouter(channelStore, descriptorStore, agentAdmin, agentStore, + secretResolver, cacheFactory); + + // Default: no legacy agents + when(agentAdmin.getDeploymentStatuses(any())).thenReturn(List.of()); + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + /** + * Set up the store mocks to return a single channel integration config. + */ + private ChannelIntegrationConfiguration setupNewStyleConfig(String channelId, + String botToken, + String signingSecret) + throws Exception { + var config = new ChannelIntegrationConfiguration(); + config.setName("Test Slack Channel"); + config.setChannelType("slack"); + config.setPlatformConfig(new HashMap<>(Map.of( + "channelId", channelId, + "botToken", botToken, + "signingSecret", signingSecret))); + config.setDefaultTargetName("default-agent"); + + var target = new ChannelTarget(); + target.setName("default-agent"); + target.setTriggers(List.of("agent")); + target.setType(ChannelTarget.TargetType.AGENT); + target.setTargetId(AGENT_ID); + config.setTargets(List.of(target)); + + // Wire descriptor → config + var descriptor = new DocumentDescriptor(); + URI resourceUri = URI.create("eddi://ai.labs.channel/channelstore/channels/" + + CHANNEL_CONFIG_ID + "?version=1"); + descriptor.setResource(resourceUri); + when(descriptorStore.readDescriptors(eq("ai.labs.channel"), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of(descriptor)); + when(channelStore.read(eq(CHANNEL_CONFIG_ID), eq(1))) + .thenReturn(config); + + // Secret resolver: pass through (or resolve vault refs) + when(secretResolver.resolveValue(anyString())).thenAnswer(inv -> { + String val = inv.getArgument(0); + if (val.startsWith("${eddivault:")) { + return "resolved-" + val.substring(12, val.length() - 1); + } + return val; + }); + + return config; + } + + /** + * Set up a legacy ChannelConnector on a deployed agent. + */ + private void setupLegacyAgent(String agentId, String channelId, String botToken, + String signingSecret, String groupId) + throws Exception { + var connector = new ChannelConnector(); + connector.setType(URI.create("slack")); + var connConfig = new HashMap(); + connConfig.put("channelId", channelId); + connConfig.put("botToken", botToken); + connConfig.put("signingSecret", signingSecret); + if (groupId != null) + connConfig.put("groupId", groupId); + connector.setConfig(connConfig); + + var agentConfig = new AgentConfiguration(); + agentConfig.setChannels(List.of(connector)); + + var desc = new DocumentDescriptor(); + desc.setDeleted(false); + + var status = new AgentDeploymentStatus( + Deployment.Environment.production, agentId, 1, + Deployment.Status.READY, desc); + + when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) + .thenReturn(List.of(status)); + when(agentStore.readAgent(eq(agentId), eq(1))).thenReturn(agentConfig); + } + + // ─── Public API — resolveTarget ──────────────────────────────────────────── + + @Nested + @DisplayName("resolveTarget — new-style integration") + class ResolveTargetNewStyle { + + @Test + @DisplayName("returns target for known channel with trigger match") + void triggerMatchViaPublicApi() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-token", "signing123"); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "agent: deploy now"); + + assertNotNull(result); + assertEquals("default-agent", result.target().getName()); + assertEquals("deploy now", result.strippedMessage()); + } + + @Test + @DisplayName("returns default target for plain message") + void defaultTargetViaPublicApi() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-token", "signing123"); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "how do I deploy?"); + + assertNotNull(result); + assertEquals("default-agent", result.target().getName()); + assertEquals("how do I deploy?", result.strippedMessage()); + } + + @Test + @DisplayName("returns null for unknown channel") + void unknownChannelReturnsNull() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-token", "signing123"); + + ResolvedTarget result = router.resolveTarget("slack", "UNKNOWN_CHANNEL", "hello"); + + assertNull(result); + } + + @Test + @DisplayName("botToken() returns resolved secret from integration config") + void botTokenResolved() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "${eddivault:slack-bot-token}", "signing123"); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNotNull(result); + assertEquals("resolved-slack-bot-token", result.botToken()); + } + + @Test + @DisplayName("signingSecret() returns resolved secret from integration config") + void signingSecretResolved() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-token", "${eddivault:slack-signing}"); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNotNull(result); + assertEquals("resolved-slack-signing", result.signingSecret()); + } + } + + // ─── Legacy fallback ─────────────────────────────────────────────────────── + + @Nested + @DisplayName("resolveTarget — legacy fallback") + class LegacyFallback { + + @Test + @DisplayName("legacy agent routes correctly when no new-style config exists") + void legacyFallbackWorks() throws Exception { + // No new-style configs + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + setupLegacyAgent(AGENT_ID, CHANNEL_ID, "xoxb-legacy", "sign-legacy", null); + when(secretResolver.resolveValue("xoxb-legacy")).thenReturn("xoxb-legacy"); + when(secretResolver.resolveValue("sign-legacy")).thenReturn("sign-legacy"); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNotNull(result); + assertEquals(ChannelTarget.TargetType.AGENT, result.target().getType()); + assertEquals(AGENT_ID, result.target().getTargetId()); + assertEquals("xoxb-legacy", result.legacyBotToken()); + } + + @Test + @DisplayName("legacy agent with groupId routes to GROUP type") + void legacyGroupRouting() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + setupLegacyAgent(AGENT_ID, CHANNEL_ID, "xoxb-leg", "sign-leg", "group-123"); + when(secretResolver.resolveValue(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNotNull(result); + assertEquals(ChannelTarget.TargetType.GROUP, result.target().getType()); + assertEquals("group-123", result.target().getTargetId()); + } + + @Test + @DisplayName("new-style config suppresses legacy for same channelId") + void newStyleSuppressesLegacy() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-new", "sign-new"); + setupLegacyAgent(AGENT_ID, CHANNEL_ID, "xoxb-legacy", "sign-legacy", null); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNotNull(result); + // Should come from new-style config, not legacy + assertEquals("default-agent", result.target().getName()); + assertEquals("xoxb-new", result.botToken()); + } + } + + // ─── Signing secrets ─────────────────────────────────────────────────────── + + @Nested + @DisplayName("getSigningSecrets") + class SigningSecrets { + + @Test + @DisplayName("returns resolved signing secrets from new-style configs") + void newStyleSigningSecrets() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-token", "${eddivault:slack-signing}"); + + Set secrets = router.getSigningSecrets("slack"); + + assertFalse(secrets.isEmpty()); + assertTrue(secrets.contains("resolved-slack-signing")); + assertFalse(secrets.contains("${eddivault:slack-signing}"), + "Should contain resolved secret, not vault reference"); + } + + @Test + @DisplayName("includes legacy signing secrets") + void legacySigningSecrets() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + setupLegacyAgent(AGENT_ID, CHANNEL_ID, "xoxb-leg", "sign-legacy", null); + when(secretResolver.resolveValue("xoxb-leg")).thenReturn("xoxb-leg"); + when(secretResolver.resolveValue("sign-legacy")).thenReturn("sign-legacy-resolved"); + + Set secrets = router.getSigningSecrets("slack"); + + assertTrue(secrets.contains("sign-legacy-resolved")); + } + + @Test + @DisplayName("returns empty for non-slack channel type") + void nonSlackReturnsEmpty() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "token", "secret"); + + Set secrets = router.getSigningSecrets("teams"); + + assertTrue(secrets.isEmpty()); + } + } + + // ─── Channel detection ───────────────────────────────────────────────────── + + @Nested + @DisplayName("hasAnyChannels & getIntegration") + class ChannelDetection { + + @Test + @DisplayName("hasAnyChannels returns true for slack with new-style config") + void hasSlackChannels() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "token", "secret"); + + assertTrue(router.hasAnyChannels("slack")); + } + + @Test + @DisplayName("hasAnyChannels returns true for slack with legacy only") + void hasLegacyChannels() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + setupLegacyAgent(AGENT_ID, CHANNEL_ID, "token", "secret", null); + when(secretResolver.resolveValue(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + assertTrue(router.hasAnyChannels("slack")); + } + + @Test + @DisplayName("hasAnyChannels returns false for teams with only slack configs") + void noTeamsChannels() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "token", "secret"); + + assertFalse(router.hasAnyChannels("teams")); + } + + @Test + @DisplayName("getIntegration returns config for known channel") + void getIntegrationKnownChannel() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "token", "secret"); + + Optional result = router.getIntegration("slack", CHANNEL_ID); + + assertTrue(result.isPresent()); + assertEquals("Test Slack Channel", result.get().getName()); + } + + @Test + @DisplayName("getIntegration returns empty for unknown channel") + void getIntegrationUnknownChannel() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "token", "secret"); + + Optional result = router.getIntegration("slack", "UNKNOWN"); + + assertTrue(result.isEmpty()); + } + } + + // ─── Deep copy safety ────────────────────────────────────────────────────── + + @Nested + @DisplayName("Deep copy safety") + class DeepCopySafety { + + @Test + @DisplayName("resolved config does not mutate store's cached original") + void resolvedDoesNotMutateOriginal() throws Exception { + var original = setupNewStyleConfig(CHANNEL_ID, "${eddivault:bot-token}", "${eddivault:signing}"); + + // Trigger refresh + router.resolveTarget("slack", CHANNEL_ID, "hello"); + + // Original should still have vault references + assertEquals("${eddivault:bot-token}", original.getPlatformConfig().get("botToken")); + assertEquals("${eddivault:signing}", original.getPlatformConfig().get("signingSecret")); + } + + @Test + @DisplayName("returned integration has resolved secrets") + void returnedConfigHasResolvedSecrets() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "${eddivault:bot-token}", "${eddivault:signing}"); + + Optional result = router.getIntegration("slack", CHANNEL_ID); + + assertTrue(result.isPresent()); + assertEquals("resolved-bot-token", result.get().getPlatformConfig().get("botToken")); + } + } + + // ─── Refresh mechanism ───────────────────────────────────────────────────── + + @Nested + @DisplayName("Refresh mechanism") + class RefreshMechanism { + + @Test + @DisplayName("refresh loads data on first call") + void refreshOnFirstCall() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "token", "secret"); + + // First call triggers refresh + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNotNull(result); + verify(descriptorStore, times(1)) + .readDescriptors(eq("ai.labs.channel"), anyString(), anyInt(), anyInt(), anyBoolean()); + } + + @Test + @DisplayName("rapid successive calls do not re-refresh") + void noReRefreshWithinInterval() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "token", "secret"); + + // Two rapid calls + router.resolveTarget("slack", CHANNEL_ID, "first"); + router.resolveTarget("slack", CHANNEL_ID, "second"); + + // Only one refresh + verify(descriptorStore, times(1)) + .readDescriptors(eq("ai.labs.channel"), anyString(), anyInt(), anyInt(), anyBoolean()); + } + + @Test + @DisplayName("refresh handles store exception gracefully") + void refreshHandlesStoreException() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenThrow(new RuntimeException("DB down")); + + // Should not throw + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNull(result); // No configs loaded + } + + @Test + @DisplayName("refresh handles null channelId gracefully") + void refreshSkipsNullChannelId() throws Exception { + var config = new ChannelIntegrationConfiguration(); + config.setName("No ChannelId Config"); + config.setChannelType("slack"); + config.setPlatformConfig(new HashMap<>(Map.of("botToken", "tok"))); + // No channelId in platformConfig + + var descriptor = new DocumentDescriptor(); + descriptor.setResource(URI.create("eddi://ai.labs.channel/channelstore/channels/" + + CHANNEL_CONFIG_ID + "?version=1")); + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of(descriptor)); + when(channelStore.read(eq(CHANNEL_CONFIG_ID), eq(1))).thenReturn(config); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNull(result); // Config skipped due to missing channelId + } + + @Test + @DisplayName("refresh handles null config from store") + void refreshHandlesNullConfig() throws Exception { + var descriptor = new DocumentDescriptor(); + descriptor.setResource(URI.create("eddi://ai.labs.channel/channelstore/channels/" + + CHANNEL_CONFIG_ID + "?version=1")); + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of(descriptor)); + when(channelStore.read(eq(CHANNEL_CONFIG_ID), eq(1))).thenReturn(null); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNull(result); + } + } + + // ─── Secret resolution ───────────────────────────────────────────────────── + + @Nested + @DisplayName("Secret resolution") + class SecretResolution { + + @Test + @DisplayName("null secret value resolves to null") + void nullSecret() throws Exception { + var config = new ChannelIntegrationConfiguration(); + config.setName("Test"); + config.setChannelType("slack"); + config.setPlatformConfig(new HashMap<>(Map.of("channelId", CHANNEL_ID))); + config.setDefaultTargetName("default"); + + var target = new ChannelTarget(); + target.setName("default"); + target.setTargetId(AGENT_ID); + target.setTriggers(List.of("default")); + config.setTargets(List.of(target)); + + var descriptor = new DocumentDescriptor(); + descriptor.setResource(URI.create("eddi://ai.labs.channel/channelstore/channels/" + + CHANNEL_CONFIG_ID + "?version=1")); + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of(descriptor)); + when(channelStore.read(eq(CHANNEL_CONFIG_ID), eq(1))).thenReturn(config); + when(secretResolver.resolveValue(CHANNEL_ID)).thenReturn(CHANNEL_ID); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + // Should resolve without error even though botToken/signingSecret are absent + assertNotNull(result); + } + + @Test + @DisplayName("secret resolver failure returns null for that secret") + void secretResolverFailure() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-good", "sign-good"); + // Override: make resolver throw for one key + when(secretResolver.resolveValue("xoxb-good")).thenThrow(new RuntimeException("vault down")); + when(secretResolver.resolveValue("sign-good")).thenReturn("sign-resolved"); + when(secretResolver.resolveValue(CHANNEL_ID)).thenReturn(CHANNEL_ID); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + + assertNotNull(result); + // botToken should be null (failed resolution), signing should work + assertNull(result.botToken()); + assertEquals("sign-resolved", result.signingSecret()); + } + } + + // ─── ResolvedTarget record ───────────────────────────────────────────────── + + @Nested + @DisplayName("ResolvedTarget credential accessors") + class ResolvedTargetAccessors { + + @Test + @DisplayName("botToken() falls back to legacyBotToken when integration is null") + void botTokenFallback() { + var target = new ChannelTarget(); + target.setName("test"); + var resolved = new ResolvedTarget(target, "msg", null, "legacy-bot", null); + + assertEquals("legacy-bot", resolved.botToken()); + } + + @Test + @DisplayName("signingSecret() falls back to legacySigningSecret when integration is null") + void signingSecretFallback() { + var target = new ChannelTarget(); + target.setName("test"); + var resolved = new ResolvedTarget(target, "msg", null, null, "legacy-sign"); + + assertEquals("legacy-sign", resolved.signingSecret()); + } + + @Test + @DisplayName("botToken() prefers integration over legacy") + void botTokenPrefersIntegration() { + var target = new ChannelTarget(); + target.setName("test"); + var integration = new ChannelIntegrationConfiguration(); + integration.setPlatformConfig(Map.of("botToken", "integration-bot")); + var resolved = new ResolvedTarget(target, "msg", integration, "legacy-bot", null); + + assertEquals("integration-bot", resolved.botToken()); + } + + @Test + @DisplayName("botToken() returns null when both sources are null") + void botTokenBothNull() { + var target = new ChannelTarget(); + target.setName("test"); + var resolved = new ResolvedTarget(target, "msg", null, null, null); + + assertNull(resolved.botToken()); + } + } + + // ─── LegacyTarget record ─────────────────────────────────────────────────── + + @Nested + @DisplayName("LegacyTarget.toChannelTarget") + class LegacyTargetConversion { + + @Test + @DisplayName("without groupId → AGENT type") + void withoutGroupId() { + var legacy = new ChannelTargetRouter.LegacyTarget("agent-1", "tok", "sign", null); + ChannelTarget target = legacy.toChannelTarget(); + + assertEquals("default", target.getName()); + assertEquals(ChannelTarget.TargetType.AGENT, target.getType()); + assertEquals("agent-1", target.getTargetId()); + } + + @Test + @DisplayName("with groupId → GROUP type") + void withGroupId() { + var legacy = new ChannelTargetRouter.LegacyTarget("agent-1", "tok", "sign", "group-abc"); + ChannelTarget target = legacy.toChannelTarget(); + + assertEquals("default", target.getName()); + assertEquals(ChannelTarget.TargetType.GROUP, target.getType()); + assertEquals("group-abc", target.getTargetId()); + } + } + + // ─── Test helper: simple ConcurrentHashMap-based ICache ───────────────── + + private static class MapCache extends ConcurrentHashMap implements ICache { + @Override + public String getCacheName() { + return "test-cache"; + } + + @Override + public V put(K key, V value, long lifespan, TimeUnit unit) { + return put(key, value); + } + + @Override + public V putIfAbsent(K key, V value, long lifespan, TimeUnit unit) { + return putIfAbsent(key, value); + } + + @Override + public void putAll(Map map, long lifespan, TimeUnit unit) { + putAll(map); + } + + @Override + public V replace(K key, V value, long lifespan, TimeUnit unit) { + return replace(key, value); + } + + @Override + public boolean replace(K key, V oldValue, V value, long lifespan, TimeUnit unit) { + return replace(key, oldValue, value); + } + + @Override + public V put(K key, V value, long lifespan, TimeUnit lifespanUnit, long maxIdleTime, TimeUnit maxIdleTimeUnit) { + return put(key, value); + } + + @Override + public V putIfAbsent(K key, V value, long lifespan, TimeUnit lifespanUnit, long maxIdleTime, TimeUnit maxIdleTimeUnit) { + return putIfAbsent(key, value); + } + } +} From ded7110f4374f392de6dc04ca363f5e481c2b00f Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 19 Apr 2026 00:47:27 +0200 Subject: [PATCH 17/35] docs(changelog): review hardening and test coverage entry --- docs/changelog.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 954e20904..f679dae12 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,6 +13,34 @@ Each entry follows this format: - **Decision** — Key design decisions and their reasoning - **Files** — Links to modified files +## Channel Integration — Review Hardening & Test Coverage (2026-04-19) + +**Repo:** EDDI (`feature/channel-integrations`) + +### Critical Bugs Fixed +- **R1 — Compilation failure:** `ChannelConnectorMigration` called `readAgent()` on `IAgentStore`, which + only has `read()` (inherited from `IResourceStore`). `readAgent()` is on `IRestAgentStore`. Was masked by + incremental compilation; `mvnw clean compile` failed immediately. Fixed to `agentStore.read()`. +- **R2 — Signing secret resolution:** `ChannelTargetRouter.refreshInternal()` collected signing secrets from + the store's cached config (containing vault references like `${eddivault:...}`) instead of the deep-copied + config with resolved secrets. Slack webhook HMAC verification would always fail for vaulted secrets. + +### Test Coverage Expansion (42 → 73 tests) +- New `ChannelTargetRouterRefreshTest` (31 tests) covering: + - Public API `resolveTarget()` with mocked stores (new-style + legacy) + - Secret resolution (vault refs, resolver failures, absent keys) + - Legacy fallback (agent routing, group routing, new-style suppression) + - Channel detection (`hasAnyChannels`, `getIntegration`) + - Deep copy safety (store original unchanged after resolution) + - Refresh mechanism (first-call load, interval gate, error resilience) + - `ResolvedTarget` accessor logic (integration vs legacy preference) + - `LegacyTarget.toChannelTarget()` conversion + +**Files:** +- `src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java` — `readAgent` → `read` +- `src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java` — signing secret from `copy` +- `src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java` — [NEW] + ## Channel Integration — Startup Migration & Legacy Deprecation (2026-04-18) **Repo:** EDDI (`feature/channel-integrations`) From aa759f039e1fb9f6f3fa38068da6da3a212d3043 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 19 Apr 2026 01:09:21 +0200 Subject: [PATCH 18/35] chore(channels): remove unused IResourceStore import --- .../ai/labs/eddi/integrations/channels/ChannelTargetRouter.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index 8c08ea8bd..ce4b64a67 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -7,7 +7,6 @@ import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; import ai.labs.eddi.configs.channels.model.ChannelTarget; import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; -import ai.labs.eddi.datastore.IResourceStore; import ai.labs.eddi.engine.api.IRestAgentAdministration; import ai.labs.eddi.engine.caching.ICache; import ai.labs.eddi.engine.caching.ICacheFactory; From 4733411602f0268c53a309d1b029a216531bd332 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 19 Apr 2026 02:21:40 +0200 Subject: [PATCH 19/35] =?UTF-8?q?fix(channels):=20address=20review=20bugs?= =?UTF-8?q?=20=E2=80=94=20legacy=20posting,=20duplicate=20channelId,=20res?= =?UTF-8?q?erved=20triggers,=20NPE=20guard,=20migration=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bug #1: postMessage now uses getBotToken() which checks both integrationMap and legacyMap, fixing silent follow-up failure in legacy-only channels - Bug #3: REST validation rejects duplicate channelType:channelId across configs, preventing silent overwrites in the router's integrationMap - Bug #4: Reject reserved 'help' keyword as trigger (would never fire due to router short-circuit) - Bug #5: Null guard on trigger.toLowerCase() in resolveFromIntegration - Bug #2: Migration warns on credential divergence across agents sharing a channel - Bug #10: Migration uses agent descriptor name (slugified) for target names instead of raw ObjectId slugs, making triggers human-typeable - 7 new tests: 3 reserved trigger rejection, 4 getBotToken (80 total) --- .../rest/RestChannelIntegrationStore.java | 71 ++++++++++++++++- .../migration/ChannelConnectorMigration.java | 78 ++++++++++++++++--- .../channels/ChannelTargetRouter.java | 28 ++++++- .../integrations/slack/SlackEventHandler.java | 5 +- ...ChannelIntegrationStoreValidationTest.java | 46 +++++++++++ .../ChannelTargetRouterRefreshTest.java | 51 ++++++++++++ 6 files changed, 263 insertions(+), 16 deletions(-) diff --git a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java index 27fe18db2..1744994fd 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java @@ -18,6 +18,7 @@ import java.net.URI; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; /** @@ -36,6 +37,11 @@ public class RestChannelIntegrationStore implements IRestChannelIntegrationStore */ private static final Set REGISTERED_CHANNEL_TYPES = Set.of("slack"); + /** + * Trigger keywords that are reserved by the router and must not be configured. + */ + private static final Set RESERVED_TRIGGERS = Set.of("help"); + private final IChannelIntegrationStore channelStore; private final IDocumentDescriptorStore documentDescriptorStore; private final RestVersionInfo restVersionInfo; @@ -62,6 +68,7 @@ public ChannelIntegrationConfiguration readChannel(String id, Integer version) { public Response updateChannel(String id, Integer version, ChannelIntegrationConfiguration channelConfiguration) { validateConfiguration(channelConfiguration); + validateUniqueChannelId(channelConfiguration, id); Response response = restVersionInfo.update(id, version, channelConfiguration); syncDescriptor(id, channelConfiguration); return response; @@ -70,6 +77,7 @@ public Response updateChannel(String id, Integer version, @Override public Response createChannel(ChannelIntegrationConfiguration channelConfiguration) { validateConfiguration(channelConfiguration); + validateUniqueChannelId(channelConfiguration, null); Response response = restVersionInfo.create(channelConfiguration); URI location = response.getLocation(); if (location != null) { @@ -179,7 +187,12 @@ void validateConfiguration(ChannelIntegrationConfiguration config) { "Target '" + target.getName() + "' contains a null or blank trigger keyword."); } - String normalized = trigger.toLowerCase().trim(); + String normalized = trigger.toLowerCase(Locale.ROOT).trim(); + if (RESERVED_TRIGGERS.contains(normalized)) { + throw new BadRequestException( + "Trigger '" + trigger + + "' is a reserved keyword and cannot be used."); + } if (!allTriggers.add(normalized)) { throw new BadRequestException( "Duplicate trigger keyword: '" + trigger @@ -190,6 +203,62 @@ void validateConfiguration(ChannelIntegrationConfiguration config) { } } + // ─── Channel ID uniqueness ───────────────────────────────────────────────── + + /** + * Reject create/update if another non-deleted config already claims the same + * {@code channelType:channelId}. Prevents silent overwrites in the router's + * integrationMap. + * + * @param excludeId + * the resource ID of the config being updated (null on create) + */ + private void validateUniqueChannelId(ChannelIntegrationConfiguration config, String excludeId) { + if (config.getPlatformConfig() == null) + return; + String channelId = config.getPlatformConfig().get("channelId"); + if (channelId == null || channelId.isBlank()) + return; + String channelType = config.getChannelType(); + if (channelType == null) + return; + + try { + var descriptors = documentDescriptorStore.readDescriptors( + "ai.labs.channel", "", 0, 1000, false); + for (var descriptor : descriptors) { + try { + var resId = RestUtilities.extractResourceId(descriptor.getResource()); + if (resId == null || resId.getId() == null) + continue; + // Skip the config being updated + if (resId.getId().equals(excludeId)) + continue; + + var existing = channelStore.read(resId.getId(), resId.getVersion()); + if (existing != null + && existing.getPlatformConfig() != null + && channelType.equalsIgnoreCase(existing.getChannelType()) + && channelId.equals(existing.getPlatformConfig().get("channelId"))) { + throw new BadRequestException( + "Another channel integration ('" + + (existing.getName() != null ? existing.getName() : resId.getId()) + + "') already uses channelId '" + channelId + + "' for type '" + channelType + "'."); + } + } catch (BadRequestException e) { + throw e; // re-throw validation errors + } catch (Exception e) { + LOG.debugf("Skipping descriptor during uniqueness check: %s", e.getMessage()); + } + } + } catch (BadRequestException e) { + throw e; + } catch (Exception e) { + LOG.warn("Failed to check channel ID uniqueness — allowing save", e); + } + } + // ─── Descriptor sync ─────────────────────────────────────────────────────── /** diff --git a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java index a96504f63..902087d0a 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java @@ -7,12 +7,14 @@ import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; import ai.labs.eddi.configs.channels.model.ChannelTarget; import ai.labs.eddi.configs.deployment.IDeploymentStore; +import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; import ai.labs.eddi.configs.migration.model.MigrationLog; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.jboss.logging.Logger; import java.util.*; +import java.util.Locale; import static ai.labs.eddi.configs.deployment.model.DeploymentInfo.DeploymentStatus.deployed; @@ -40,16 +42,19 @@ public class ChannelConnectorMigration { private final IDeploymentStore deploymentStore; private final IAgentStore agentStore; private final IChannelIntegrationStore channelStore; + private final IDocumentDescriptorStore descriptorStore; private final MigrationLogStore migrationLogStore; @Inject public ChannelConnectorMigration(IDeploymentStore deploymentStore, IAgentStore agentStore, IChannelIntegrationStore channelStore, + IDocumentDescriptorStore descriptorStore, MigrationLogStore migrationLogStore) { this.deploymentStore = deploymentStore; this.agentStore = agentStore; this.channelStore = channelStore; + this.descriptorStore = descriptorStore; this.migrationLogStore = migrationLogStore; } @@ -100,10 +105,11 @@ private int migrateConnectors() { if (channelId == null || channelId.isBlank()) { continue; } - String channelType = connector.getType().toString().toLowerCase(); + String channelType = connector.getType().toString().toLowerCase(Locale.ROOT); String groupKey = channelType + ":" + channelId; channelGroups.computeIfAbsent(groupKey, k -> new ArrayList<>()) - .add(new ConnectorEntry(connector, agentId, channelType)); + .add(new ConnectorEntry(connector, agentId, channelType, + lookupAgentName(agentId, status.getAgentVersion()))); } } catch (Exception e) { LOGGER.warnf("Skipping agent %s during channel migration: %s", agentId, e.getMessage()); @@ -120,6 +126,9 @@ private int migrateConnectors() { // Sort for deterministic default target entries.sort(Comparator.comparing(ConnectorEntry::agentId)); + // Warn on credential divergence across agents sharing the same channel + warnOnCredentialDivergence(entry.getKey(), entries); + var first = entries.get(0); String channelId = first.connector().getConfig().get("channelId"); String channelType = first.channelType(); @@ -134,13 +143,11 @@ private int migrateConnectors() { for (var ce : entries) { var target = new ChannelTarget(); - String baseName = ce.agentId().toLowerCase().replaceAll("[^a-z0-9-]", "-"); + String baseName = slugify(ce.agentName() != null ? ce.agentName() : ce.agentId()); String targetName = baseName; - if (!usedNames.add(targetName)) { - int counter = 2; - while (!usedNames.add(targetName)) { - targetName = baseName + "-" + counter++; - } + int counter = 2; + while (!usedNames.add(targetName)) { + targetName = baseName + "-" + counter++; } target.setName(targetName); target.setType(ChannelTarget.TargetType.AGENT); @@ -171,6 +178,59 @@ private int migrateConnectors() { return created; } - private record ConnectorEntry(ChannelConnector connector, String agentId, String channelType) { + private record ConnectorEntry(ChannelConnector connector, String agentId, + String channelType, String agentName) { + } + + /** + * Look up the human-readable name for an agent via its document descriptor. + * Returns {@code null} if the descriptor is not found or has no name. + */ + private String lookupAgentName(String agentId, Integer version) { + try { + var descriptor = descriptorStore.readDescriptor(agentId, version); + if (descriptor != null && descriptor.getName() != null && !descriptor.getName().isBlank()) { + return descriptor.getName(); + } + } catch (Exception e) { + LOGGER.debugf("Could not read descriptor for agent %s: %s", agentId, e.getMessage()); + } + return null; + } + + /** + * Slugify a name for use as a target name / trigger keyword. + */ + private static String slugify(String input) { + return input.toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9-]+", "-") + .replaceAll("-{2,}", "-") + .replaceAll("^-|-$", ""); + } + + /** + * Log WARN if agents sharing the same channelId have divergent credentials, so + * operators can reconcile manually after migration. + */ + private void warnOnCredentialDivergence(String groupKey, List entries) { + if (entries.size() < 2) + return; + String refBotToken = entries.get(0).connector().getConfig().get("botToken"); + String refSigning = entries.get(0).connector().getConfig().get("signingSecret"); + + var divergentAgents = new ArrayList(); + for (int i = 1; i < entries.size(); i++) { + var cfg = entries.get(i).connector().getConfig(); + if (!Objects.equals(refBotToken, cfg.get("botToken")) + || !Objects.equals(refSigning, cfg.get("signingSecret"))) { + divergentAgents.add(entries.get(i).agentId()); + } + } + if (!divergentAgents.isEmpty()) { + LOGGER.warnf(" CREDENTIAL DIVERGENCE for %s — agents %s have different " + + "botToken/signingSecret than %s. Only the first agent's credentials " + + "will be used. Please reconcile manually after migration.", + groupKey, divergentAgents, entries.get(0).agentId()); + } } } diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index ce4b64a67..18d67883e 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -17,9 +17,9 @@ import jakarta.inject.Inject; import org.jboss.logging.Logger; +import java.time.Duration; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; -import java.time.Duration; import static ai.labs.eddi.utils.RestUtilities.extractResourceId; @@ -179,6 +179,30 @@ public Optional getIntegration(String channelTy return Optional.ofNullable(integrationMap.get(channelType + ":" + platformChannelId)); } + /** + * Get the bot token for a channel, checking new-style integrations first, then + * legacy. Returns {@code null} if no token is configured for this channel. + */ + public String getBotToken(String channelType, String platformChannelId) { + refreshIfNeeded(); + String key = channelType + ":" + platformChannelId; + ChannelIntegrationConfiguration integration = integrationMap.get(key); + if (integration != null && integration.getPlatformConfig() != null) { + String token = integration.getPlatformConfig().get("botToken"); + if (token != null && !token.isBlank()) { + return token; + } + } + // Fallback: legacy map (Slack only) + if (CHANNEL_TYPE_SLACK.equals(channelType)) { + LegacyTarget legacy = legacyMap.get(platformChannelId); + if (legacy != null && legacy.botToken() != null) { + return legacy.botToken(); + } + } + return null; + } + /** * Check if any channel integrations are configured (new or legacy). */ @@ -228,7 +252,7 @@ ResolvedTarget resolveFromIntegration(ChannelIntegrationConfiguration integratio for (ChannelTarget target : integration.getTargets()) { if (target.getTriggers() != null) { for (String trigger : target.getTriggers()) { - if (trigger.toLowerCase().trim().equals(candidateTrigger)) { + if (trigger != null && trigger.toLowerCase().trim().equals(candidateTrigger)) { return new ResolvedTarget(target, remainder, integration, null, null); } diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java index f841895b2..6c977e911 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -501,10 +501,7 @@ private void postMessage(String channelId, String threadTs, String text) { botToken = resolved.botToken(); } if (botToken == null || botToken.isEmpty()) { - var integration = channelTargetRouter.getIntegration("slack", channelId); - if (integration.isPresent() && integration.get().getPlatformConfig() != null) { - botToken = integration.get().getPlatformConfig().get("botToken"); - } + botToken = channelTargetRouter.getBotToken("slack", channelId); } if (botToken == null || botToken.isEmpty()) { diff --git a/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java index e3a0940d5..13794ef61 100644 --- a/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java +++ b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java @@ -281,4 +281,50 @@ void observeModeFalse() { assertDoesNotThrow(() -> store.validateConfiguration(config)); } } + + // ─── Reserved triggers ──────────────────────────────────────────────────── + + @Nested + @DisplayName("Reserved trigger validation") + class ReservedTriggerValidation { + + @Test + @DisplayName("trigger 'help' → BadRequest (reserved)") + void helpTriggerRejected() { + var target = new ChannelTarget(); + target.setName("support"); + target.setTargetId("agent-abc"); + target.setTriggers(List.of("help")); + config.setTargets(List.of(target)); + + var ex = assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().contains("reserved")); + } + + @Test + @DisplayName("trigger 'HELP' → BadRequest (case-insensitive)") + void helpUpperCaseRejected() { + var target = new ChannelTarget(); + target.setName("support"); + target.setTargetId("agent-abc"); + target.setTriggers(List.of("HELP")); + config.setTargets(List.of(target)); + + assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + } + + @Test + @DisplayName("trigger 'helper' → passes (not reserved)") + void helperAllowed() { + var target = new ChannelTarget(); + target.setName("support"); + target.setTargetId("agent-abc"); + target.setTriggers(List.of("helper")); + config.setTargets(List.of(target)); + + assertDoesNotThrow(() -> store.validateConfiguration(config)); + } + } } diff --git a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java index 88489cf61..abb0524b9 100644 --- a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java @@ -608,6 +608,57 @@ void withGroupId() { } } + // ─── getBotToken ─────────────────────────────────────────────────────────── + + @Nested + @DisplayName("getBotToken — unified token lookup") + class GetBotTokenTests { + + @Test + @DisplayName("returns bot token from new-style integration") + void newStyleToken() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-integration", "signing123"); + + String token = router.getBotToken("slack", CHANNEL_ID); + + assertEquals("xoxb-integration", token); + } + + @Test + @DisplayName("returns bot token from legacy when no new-style config exists") + void legacyFallbackToken() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + setupLegacyAgent(AGENT_ID, CHANNEL_ID, "xoxb-legacy", "sign", null); + when(secretResolver.resolveValue(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + String token = router.getBotToken("slack", CHANNEL_ID); + + assertEquals("xoxb-legacy", token); + } + + @Test + @DisplayName("new-style token takes precedence over legacy") + void newStylePrecedence() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-new", "sign-new"); + setupLegacyAgent(AGENT_ID, CHANNEL_ID, "xoxb-legacy", "sign-legacy", null); + + String token = router.getBotToken("slack", CHANNEL_ID); + + assertEquals("xoxb-new", token); + } + + @Test + @DisplayName("returns null for unknown channel") + void unknownChannelReturnsNull() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-token", "signing"); + + String token = router.getBotToken("slack", "UNKNOWN_CHANNEL"); + + assertNull(token); + } + } + // ─── Test helper: simple ConcurrentHashMap-based ICache ───────────────── private static class MapCache extends ConcurrentHashMap implements ICache { From 2e3c8310f6a197e7cd0cffc5a2a3377a0e804923 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 19 Apr 2026 02:22:48 +0200 Subject: [PATCH 20/35] =?UTF-8?q?docs(changelog):=20external=20review=20ro?= =?UTF-8?q?und=204=20=E2=80=94=206=20bugs=20fixed,=2080=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index f679dae12..5e6b5f0f1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -13,6 +13,37 @@ Each entry follows this format: - **Decision** — Key design decisions and their reasoning - **Files** — Links to modified files +## Channel Integration — External Review Round 4 (2026-04-19) + +**Repo:** EDDI (`feature/channel-integrations`) + +### Bugs Fixed (6 findings from external review) +- **#1 — Legacy follow-up posting:** `postMessage` fell back to `getIntegration()` which only + checked `integrationMap`, not `legacyMap`. Legacy-only channels silently failed to post responses. + Fixed by adding `getBotToken()` method that checks both maps. +- **#3 — Duplicate channelId:** REST validation now rejects create/update if another non-deleted + config already claims the same `channelType:channelId`. Prevents silent overwrites in the router. +- **#4 — Reserved triggers:** `"help"` is now rejected as a trigger keyword — it would never fire + because the router short-circuits on `help` before trigger matching. +- **#5 — NPE guard:** Added null check on `trigger.toLowerCase()` in `resolveFromIntegration` for + data that bypasses REST validation (e.g., raw MongoDB writes, imported ZIPs). +- **#2 — Migration credential divergence:** Migration now logs WARN when agents sharing the same + channelId have different botToken/signingSecret values, with affected agentIds listed. +- **#10 — Migration target names:** Target names now use the agent's descriptor name (slugified) + instead of raw ObjectId strings, making trigger keywords human-typeable. + +### Test Coverage (73 → 80 tests) +- 3 new reserved trigger validation tests +- 4 new `getBotToken()` tests (new-style, legacy fallback, precedence, unknown) + +**Files:** +- `ChannelTargetRouter.java` — `getBotToken()`, null guard, import order +- `SlackEventHandler.java` — use `getBotToken()` instead of `getIntegration()` in `postMessage` +- `RestChannelIntegrationStore.java` — reserved triggers, `validateUniqueChannelId()`, `Locale.ROOT` +- `ChannelConnectorMigration.java` — descriptor name lookup, slugify, divergence warning +- `RestChannelIntegrationStoreValidationTest.java` — 3 reserved trigger tests +- `ChannelTargetRouterRefreshTest.java` — 4 getBotToken tests + ## Channel Integration — Review Hardening & Test Coverage (2026-04-19) **Repo:** EDDI (`feature/channel-integrations`) From 2121f18c1f8e2f6114ddd7d196328810f9c81fce Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 25 Apr 2026 01:34:44 +0200 Subject: [PATCH 21/35] test(channels): comprehensive test coverage for channel integration subsystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelModelTest: POJO tests for ChannelIntegrationConfiguration, ChannelTarget, ObserveConfig (all 100% instruction coverage) - ChannelConnectorMigrationTest: full migration logic coverage including skip logic, multi-agent merging, groupId routing, platformConfig cleaning, slugify edge cases, reserved triggers, credential divergence (97% instruction, 86.2% branch) - RestSlackWebhookTest: disabled state, signature verification, URL verification challenge, event callback delegation, malformed payloads (100% instruction, 100% branch) - ChannelTargetRouterRefreshTest: added legacy credential attachment for thread targets, deleted/null descriptor handling, non-slack connector skipping, blank groupId → AGENT type - ChannelTargetRouter: 97.4% instruction, 84.1% branch coverage Aggregate unit-testable class coverage: 95.9% instruction, 84.6% branch --- .../rest/RestChannelIntegrationStore.java | 8 +- .../migration/ChannelConnectorMigration.java | 29 +- .../channels/ChannelTargetRouter.java | 64 ++- .../integrations/slack/SlackEventHandler.java | 2 +- .../channels/model/ChannelModelTest.java | 144 +++++ ...ChannelIntegrationStoreValidationTest.java | 23 +- .../ChannelConnectorMigrationTest.java | 493 ++++++++++++++++++ .../ChannelTargetRouterRefreshTest.java | 175 +++++++ .../channels/ChannelTargetRouterTest.java | 337 +++++++++++- .../slack/rest/RestSlackWebhookTest.java | 190 +++++++ 10 files changed, 1433 insertions(+), 32 deletions(-) create mode 100644 src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java create mode 100644 src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java create mode 100644 src/test/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhookTest.java diff --git a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java index 1744994fd..761df06c1 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java @@ -95,6 +95,12 @@ public Response createChannel(ChannelIntegrationConfiguration channelConfigurati public Response duplicateChannel(String id, Integer version) { restVersionInfo.validateParameters(id, version); ChannelIntegrationConfiguration config = restVersionInfo.read(id, version); + // Clear channelId so the duplicate doesn't collide in the router's + // integrationMap (each channelType:channelId must be unique) + if (config.getPlatformConfig() != null) { + config.getPlatformConfig().remove("channelId"); + } + validateConfiguration(config); Response response = restVersionInfo.create(config); URI location = response.getLocation(); if (location != null) { @@ -137,7 +143,7 @@ void validateConfiguration(ChannelIntegrationConfiguration config) { if (channelType == null || channelType.isBlank()) { throw new BadRequestException("Channel type is required."); } - if (!REGISTERED_CHANNEL_TYPES.contains(channelType.toLowerCase())) { + if (!REGISTERED_CHANNEL_TYPES.contains(channelType.toLowerCase(Locale.ROOT))) { throw new BadRequestException( "Unknown channel type: '" + channelType + "'. Registered types: " + REGISTERED_CHANNEL_TYPES); diff --git a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java index 902087d0a..a5eb7d39e 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java @@ -39,6 +39,11 @@ public class ChannelConnectorMigration { private static final Logger LOGGER = Logger.getLogger(ChannelConnectorMigration.class); private static final String MIGRATION_KEY = "channel-connector-migration-complete"; + /** + * Trigger keywords reserved by the router — migration must not generate these. + */ + private static final Set RESERVED_TRIGGERS = Set.of("help"); + private final IDeploymentStore deploymentStore; private final IAgentStore agentStore; private final IChannelIntegrationStore channelStore; @@ -136,7 +141,16 @@ private int migrateConnectors() { var config = new ChannelIntegrationConfiguration(); config.setName(channelType + " — " + channelId); config.setChannelType(channelType); - config.setPlatformConfig(new HashMap<>(first.connector().getConfig())); + // Clean platformConfig: only carry channel-level credentials + // (channelId, botToken, signingSecret), not per-connector fields like groupId + var cleanedPlatformConfig = new HashMap(); + var rawConfig = first.connector().getConfig(); + for (String credKey : List.of("channelId", "botToken", "signingSecret")) { + if (rawConfig.containsKey(credKey)) { + cleanedPlatformConfig.put(credKey, rawConfig.get(credKey)); + } + } + config.setPlatformConfig(cleanedPlatformConfig); var targets = new ArrayList(); var usedNames = new HashSet(); @@ -159,7 +173,14 @@ private int migrateConnectors() { target.setTargetId(groupId); } - target.setTriggers(List.of(targetName)); + // Only assign trigger if it's not a reserved keyword + if (!RESERVED_TRIGGERS.contains(targetName)) { + target.setTriggers(List.of(targetName)); + } else { + LOGGER.warnf(" Skipping reserved trigger '%s' for target in channel %s:%s", + targetName, channelType, channelId); + target.setTriggers(List.of()); + } targets.add(target); } @@ -202,10 +223,12 @@ private String lookupAgentName(String agentId, Integer version) { * Slugify a name for use as a target name / trigger keyword. */ private static String slugify(String input) { - return input.toLowerCase(Locale.ROOT) + String slug = input.toLowerCase(Locale.ROOT) .replaceAll("[^a-z0-9-]+", "-") .replaceAll("-{2,}", "-") .replaceAll("^-|-$", ""); + // Fallback: if input was all special chars (emoji, etc.), use a prefix + return slug.isEmpty() ? "target" : slug; } /** diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index 18d67883e..03485861f 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -19,6 +19,7 @@ import java.time.Duration; import java.util.*; +import java.util.Locale; import java.util.concurrent.atomic.AtomicBoolean; import static ai.labs.eddi.utils.RestUtilities.extractResourceId; @@ -109,9 +110,10 @@ public ChannelTargetRouter(IChannelIntegrationStore channelStore, public ResolvedTarget resolveTarget(String channelType, String platformChannelId, String messageText) { refreshIfNeeded(); + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; // 1. Try new-style ChannelIntegrationConfiguration - String key = channelType + ":" + platformChannelId; + String key = normalizedType + ":" + platformChannelId; ChannelIntegrationConfiguration integration = integrationMap.get(key); if (integration != null) { return resolveFromIntegration(integration, messageText); @@ -119,7 +121,7 @@ public ResolvedTarget resolveTarget(String channelType, String platformChannelId // 2. Fallback: legacy ChannelConnector (only if no new-style config covers this // channel) - if (CHANNEL_TYPE_SLACK.equals(channelType)) { + if (CHANNEL_TYPE_SLACK.equals(normalizedType)) { LegacyTarget legacy = legacyMap.get(platformChannelId); if (legacy != null) { return new ResolvedTarget(legacy.toChannelTarget(), messageText, null, @@ -137,24 +139,48 @@ public ResolvedTarget resolveTarget(String channelType, String platformChannelId */ public ResolvedTarget resolveThreadTarget(String channelType, String platformChannelId, String threadTs) { - ChannelTarget locked = threadTargetLock.get(threadTs); + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + String lockKey = normalizedType + ":" + platformChannelId + ":" + threadTs; + ChannelTarget locked = threadTargetLock.get(lockKey); if (locked == null) { return null; } refreshIfNeeded(); - String key = channelType + ":" + platformChannelId; + String key = normalizedType + ":" + platformChannelId; ChannelIntegrationConfiguration integration = integrationMap.get(key); - return new ResolvedTarget(locked, null, integration, null, null); + // Attach legacy credentials when no new-style integration exists + String legacyBotToken = null; + String legacySigningSecret = null; + if (integration == null && CHANNEL_TYPE_SLACK.equals(normalizedType)) { + LegacyTarget legacy = legacyMap.get(platformChannelId); + if (legacy != null) { + legacyBotToken = legacy.botToken(); + legacySigningSecret = legacy.signingSecret(); + } + } + return new ResolvedTarget(locked, null, integration, legacyBotToken, legacySigningSecret); } /** * Lock a target for a thread. Subsequent messages in this thread will always * route to the same target, ignoring trigger keywords. + * + * @param channelType + * platform type (e.g., "slack") + * @param platformChannelId + * the platform-specific channel ID + * @param threadTs + * the thread timestamp + * @param target + * the target to lock for this thread */ - public void lockThreadTarget(String threadTs, ChannelTarget target) { - threadTargetLock.put(threadTs, target); + public void lockThreadTarget(String channelType, String platformChannelId, + String threadTs, ChannelTarget target) { + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + String lockKey = normalizedType + ":" + platformChannelId + ":" + threadTs; + threadTargetLock.put(lockKey, target); } /** @@ -163,7 +189,8 @@ public void lockThreadTarget(String threadTs, ChannelTarget target) { */ public Set getSigningSecrets(String channelType) { refreshIfNeeded(); - if (CHANNEL_TYPE_SLACK.equals(channelType)) { + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + if (CHANNEL_TYPE_SLACK.equals(normalizedType)) { return slackSigningSecrets; } return Set.of(); @@ -176,7 +203,8 @@ public Set getSigningSecrets(String channelType) { public Optional getIntegration(String channelType, String platformChannelId) { refreshIfNeeded(); - return Optional.ofNullable(integrationMap.get(channelType + ":" + platformChannelId)); + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + return Optional.ofNullable(integrationMap.get(normalizedType + ":" + platformChannelId)); } /** @@ -185,7 +213,8 @@ public Optional getIntegration(String channelTy */ public String getBotToken(String channelType, String platformChannelId) { refreshIfNeeded(); - String key = channelType + ":" + platformChannelId; + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + String key = normalizedType + ":" + platformChannelId; ChannelIntegrationConfiguration integration = integrationMap.get(key); if (integration != null && integration.getPlatformConfig() != null) { String token = integration.getPlatformConfig().get("botToken"); @@ -194,7 +223,7 @@ public String getBotToken(String channelType, String platformChannelId) { } } // Fallback: legacy map (Slack only) - if (CHANNEL_TYPE_SLACK.equals(channelType)) { + if (CHANNEL_TYPE_SLACK.equals(normalizedType)) { LegacyTarget legacy = legacyMap.get(platformChannelId); if (legacy != null && legacy.botToken() != null) { return legacy.botToken(); @@ -208,11 +237,12 @@ public String getBotToken(String channelType, String platformChannelId) { */ public boolean hasAnyChannels(String channelType) { refreshIfNeeded(); - if (CHANNEL_TYPE_SLACK.equals(channelType)) { + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + if (CHANNEL_TYPE_SLACK.equals(normalizedType)) { return integrationMap.keySet().stream().anyMatch(k -> k.startsWith("slack:")) || !legacyMap.isEmpty(); } - return integrationMap.keySet().stream().anyMatch(k -> k.startsWith(channelType + ":")); + return integrationMap.keySet().stream().anyMatch(k -> k.startsWith(normalizedType + ":")); } // ─── Trigger matching ────────────────────────────────────────────────────── @@ -246,13 +276,13 @@ ResolvedTarget resolveFromIntegration(ChannelIntegrationConfiguration integratio // Check for colon-delimited trigger int colonIdx = trimmed.indexOf(':'); if (colonIdx > 0) { - String candidateTrigger = trimmed.substring(0, colonIdx).trim().toLowerCase(); + String candidateTrigger = trimmed.substring(0, colonIdx).trim().toLowerCase(Locale.ROOT); String remainder = trimmed.substring(colonIdx + 1).trim(); for (ChannelTarget target : integration.getTargets()) { if (target.getTriggers() != null) { for (String trigger : target.getTriggers()) { - if (trigger != null && trigger.toLowerCase().trim().equals(candidateTrigger)) { + if (trigger != null && trigger.toLowerCase(Locale.ROOT).trim().equals(candidateTrigger)) { return new ResolvedTarget(target, remainder, integration, null, null); } @@ -326,13 +356,13 @@ private void refreshInternal() { if (channelId != null && !channelId.isBlank()) { var copy = deepCopyConfig(config); resolvePlatformSecrets(copy); - String key = copy.getChannelType().toLowerCase() + ":" + channelId; + String key = copy.getChannelType().toLowerCase(Locale.ROOT) + ":" + channelId; newIntegrationMap.put(key, copy); coveredChannelIds.add(channelId); // Collect signing secrets for Slack if (CHANNEL_TYPE_SLACK.equals( - config.getChannelType().toLowerCase())) { + config.getChannelType().toLowerCase(Locale.ROOT))) { String ss = copy.getPlatformConfig().get("signingSecret"); if (ss != null && !ss.isBlank()) { newSigningSecrets.add(ss); diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java index 6c977e911..79c066ad4 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -212,7 +212,7 @@ && tryHandleAgentFollowUp(parentTs, channelId, userId, text, threadTs)) { // Lock target for this thread if (threadTs != null) { - channelTargetRouter.lockThreadTarget(threadTs, resolved.target()); + channelTargetRouter.lockThreadTarget("slack", channelId, threadTs, resolved.target()); } // Store resolved target for credential resolution in postMessage diff --git a/src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java b/src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java new file mode 100644 index 000000000..83b85c3ed --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java @@ -0,0 +1,144 @@ +package ai.labs.eddi.configs.channels.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the channel integration model POJOs: + * {@link ChannelIntegrationConfiguration}, {@link ChannelTarget}, and + * {@link ObserveConfig}. + */ +class ChannelModelTest { + + // ─── ChannelIntegrationConfiguration ────────────────────────────────────── + + @Nested + @DisplayName("ChannelIntegrationConfiguration") + class IntegrationConfigTest { + + @Test + @DisplayName("default state — name/channelType null, collections empty") + void defaultState() { + var cfg = new ChannelIntegrationConfiguration(); + assertNull(cfg.getName()); + assertNull(cfg.getChannelType()); + assertNull(cfg.getDefaultTargetName()); + assertNotNull(cfg.getPlatformConfig()); + assertTrue(cfg.getPlatformConfig().isEmpty()); + assertNotNull(cfg.getTargets()); + assertTrue(cfg.getTargets().isEmpty()); + } + + @Test + @DisplayName("setters and getters round-trip") + void settersAndGetters() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setName("My Hub"); + cfg.setChannelType("slack"); + cfg.setDefaultTargetName("support"); + cfg.setPlatformConfig(Map.of("botToken", "xoxb-123")); + + var target = new ChannelTarget(); + target.setName("support"); + cfg.setTargets(List.of(target)); + + assertEquals("My Hub", cfg.getName()); + assertEquals("slack", cfg.getChannelType()); + assertEquals("support", cfg.getDefaultTargetName()); + assertEquals("xoxb-123", cfg.getPlatformConfig().get("botToken")); + assertEquals(1, cfg.getTargets().size()); + } + } + + // ─── ChannelTarget ──────────────────────────────────────────────────────── + + @Nested + @DisplayName("ChannelTarget") + class TargetTest { + + @Test + @DisplayName("default state — AGENT type, empty triggers") + void defaultState() { + var target = new ChannelTarget(); + assertEquals(ChannelTarget.TargetType.AGENT, target.getType()); + assertNotNull(target.getTriggers()); + assertTrue(target.getTriggers().isEmpty()); + assertNull(target.getName()); + assertNull(target.getTargetId()); + assertFalse(target.isObserveMode()); + assertNull(target.getObserveConfig()); + } + + @Test + @DisplayName("setters and getters round-trip") + void settersAndGetters() { + var target = new ChannelTarget(); + target.setName("architect"); + target.setTargetId("agent-123"); + target.setType(ChannelTarget.TargetType.GROUP); + target.setTriggers(List.of("arch", "architect")); + target.setObserveMode(true); + + var observeConfig = new ObserveConfig(); + target.setObserveConfig(observeConfig); + + assertEquals("architect", target.getName()); + assertEquals("agent-123", target.getTargetId()); + assertEquals(ChannelTarget.TargetType.GROUP, target.getType()); + assertEquals(2, target.getTriggers().size()); + assertTrue(target.isObserveMode()); + assertNotNull(target.getObserveConfig()); + } + + @Test + @DisplayName("TargetType enum values") + void targetTypeValues() { + assertEquals(2, ChannelTarget.TargetType.values().length); + assertNotNull(ChannelTarget.TargetType.valueOf("AGENT")); + assertNotNull(ChannelTarget.TargetType.valueOf("GROUP")); + } + } + + // ─── ObserveConfig ──────────────────────────────────────────────────────── + + @Nested + @DisplayName("ObserveConfig") + class ObserveConfigTest { + + @Test + @DisplayName("default values — sensible production defaults") + void defaultValues() { + var cfg = new ObserveConfig(); + assertNotNull(cfg.getTriggerKeywords()); + assertTrue(cfg.getTriggerKeywords().isEmpty()); + assertNotNull(cfg.getTriggerMimeTypes()); + assertTrue(cfg.getTriggerMimeTypes().isEmpty()); + assertEquals(60, cfg.getCooldownSeconds()); + assertEquals(50, cfg.getMaxDailyResponses()); + assertEquals(5.0, cfg.getMaxCostPerDay(), 0.01); + } + + @Test + @DisplayName("setters and getters round-trip") + void settersAndGetters() { + var cfg = new ObserveConfig(); + cfg.setTriggerKeywords(List.of("urgent", "help")); + cfg.setTriggerMimeTypes(List.of("application/pdf")); + cfg.setCooldownSeconds(120); + cfg.setMaxDailyResponses(100); + cfg.setMaxCostPerDay(10.50); + + assertEquals(List.of("urgent", "help"), cfg.getTriggerKeywords()); + assertEquals(List.of("application/pdf"), cfg.getTriggerMimeTypes()); + assertEquals(120, cfg.getCooldownSeconds()); + assertEquals(100, cfg.getMaxDailyResponses()); + assertEquals(10.50, cfg.getMaxCostPerDay(), 0.01); + } + } +} diff --git a/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java index 13794ef61..f3119c790 100644 --- a/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java +++ b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java @@ -138,15 +138,24 @@ void emptyTargets() { @Test @DisplayName("target with null name → BadRequest") void targetNullName() { - var target = new ChannelTarget(); - target.setName(null); - target.setTargetId("agent-x"); - target.setTriggers(List.of("x")); - config.setTargets(List.of(target)); - config.setDefaultTargetName("x"); // will fail before default check + // Include a valid target so the default-target check passes; + // the null-name target must be second to reach the per-target loop + var validTarget = new ChannelTarget(); + validTarget.setName("x"); + validTarget.setTargetId("agent-valid"); + validTarget.setTriggers(List.of("x")); - assertThrows(BadRequestException.class, + var badTarget = new ChannelTarget(); + badTarget.setName(null); + badTarget.setTargetId("agent-x"); + badTarget.setTriggers(List.of("y")); + + config.setTargets(List.of(validTarget, badTarget)); + config.setDefaultTargetName("x"); + + var ex = assertThrows(BadRequestException.class, () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().contains("name")); } @Test diff --git a/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java b/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java new file mode 100644 index 000000000..3d66e25f8 --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java @@ -0,0 +1,493 @@ +package ai.labs.eddi.configs.migration; + +import ai.labs.eddi.configs.agents.IAgentStore; +import ai.labs.eddi.configs.agents.model.AgentConfiguration; +import ai.labs.eddi.configs.agents.model.AgentConfiguration.ChannelConnector; +import ai.labs.eddi.configs.channels.IChannelIntegrationStore; +import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; +import ai.labs.eddi.configs.channels.model.ChannelTarget; +import ai.labs.eddi.configs.deployment.IDeploymentStore; +import ai.labs.eddi.configs.deployment.model.DeploymentInfo; +import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; +import ai.labs.eddi.configs.descriptors.model.DocumentDescriptor; +import ai.labs.eddi.configs.migration.model.MigrationLog; +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.ArgumentCaptor; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static ai.labs.eddi.configs.deployment.model.DeploymentInfo.DeploymentStatus.deployed; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link ChannelConnectorMigration}. Tests migration from legacy + * embedded ChannelConnectors to standalone ChannelIntegrationConfiguration. + */ +class ChannelConnectorMigrationTest { + + private IDeploymentStore deploymentStore; + private IAgentStore agentStore; + private IChannelIntegrationStore channelStore; + private IDocumentDescriptorStore descriptorStore; + private MigrationLogStore migrationLogStore; + private ChannelConnectorMigration migration; + + @BeforeEach + void setUp() { + deploymentStore = mock(IDeploymentStore.class); + agentStore = mock(IAgentStore.class); + channelStore = mock(IChannelIntegrationStore.class); + descriptorStore = mock(IDocumentDescriptorStore.class); + migrationLogStore = mock(MigrationLogStore.class); + + migration = new ChannelConnectorMigration( + deploymentStore, agentStore, channelStore, descriptorStore, migrationLogStore); + } + + // ─── Skip if already migrated ───────────────────────────────────────────── + + @Nested + @DisplayName("Migration skip logic") + class SkipLogic { + + @Test + @DisplayName("skips if migration flag already set") + void skipIfAlreadyMigrated() { + when(migrationLogStore.readMigrationLog("channel-connector-migration-complete")) + .thenReturn(new MigrationLog("channel-connector-migration-complete")); + + migration.runIfNeeded(); + + verifyNoInteractions(deploymentStore, agentStore, channelStore); + } + + @Test + @DisplayName("runs if migration flag not set") + void runsIfNotMigrated() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of()); + + migration.runIfNeeded(); + + verify(deploymentStore).readDeploymentInfos(deployed); + verify(migrationLogStore).createMigrationLog(any(MigrationLog.class)); + } + } + + // ─── Basic migration ────────────────────────────────────────────────────── + + @Nested + @DisplayName("Basic migration") + class BasicMigration { + + @Test + @DisplayName("migrates single agent with single channel connector") + void singleAgentSingleChannel() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + setupDeployedAgent("agent-1", 1, "slack", "C001", "xoxb-tok", "sign-sec", null); + + var descriptor = new DocumentDescriptor(); + descriptor.setName("Test Agent"); + when(descriptorStore.readDescriptor("agent-1", 1)).thenReturn(descriptor); + + migration.runIfNeeded(); + + var captor = ArgumentCaptor.forClass(ChannelIntegrationConfiguration.class); + verify(channelStore).create(captor.capture()); + var config = captor.getValue(); + + assertEquals("slack", config.getChannelType()); + assertEquals("slack — C001", config.getName()); + assertEquals(1, config.getTargets().size()); + assertEquals("test-agent", config.getTargets().get(0).getName()); + assertEquals(ChannelTarget.TargetType.AGENT, config.getTargets().get(0).getType()); + assertEquals("agent-1", config.getTargets().get(0).getTargetId()); + } + + @Test + @DisplayName("migrates agent with groupId → GROUP target type") + void groupIdMigration() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + setupDeployedAgent("agent-1", 1, "slack", "C001", "tok", "sign", "group-xyz"); + + when(descriptorStore.readDescriptor("agent-1", 1)).thenReturn(null); + + migration.runIfNeeded(); + + var captor = ArgumentCaptor.forClass(ChannelIntegrationConfiguration.class); + verify(channelStore).create(captor.capture()); + var config = captor.getValue(); + + assertEquals(ChannelTarget.TargetType.GROUP, config.getTargets().get(0).getType()); + assertEquals("group-xyz", config.getTargets().get(0).getTargetId()); + } + + @Test + @DisplayName("platformConfig contains only channel-level credentials, not per-connector fields") + void cleanedPlatformConfig() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + setupDeployedAgent("agent-1", 1, "slack", "C001", "tok", "sign", "group-x"); + + when(descriptorStore.readDescriptor("agent-1", 1)).thenReturn(null); + + migration.runIfNeeded(); + + var captor = ArgumentCaptor.forClass(ChannelIntegrationConfiguration.class); + verify(channelStore).create(captor.capture()); + var platformConfig = captor.getValue().getPlatformConfig(); + + assertTrue(platformConfig.containsKey("channelId")); + assertTrue(platformConfig.containsKey("botToken")); + assertTrue(platformConfig.containsKey("signingSecret")); + assertFalse(platformConfig.containsKey("groupId"), + "groupId is a per-connector field and should not leak into channel-level platformConfig"); + } + } + + // ─── Multi-agent merging ────────────────────────────────────────────────── + + @Nested + @DisplayName("Multi-agent merging") + class MultiAgentMerging { + + @Test + @DisplayName("multiple agents on same channel → merged into one config with multiple targets") + void mergesMultipleAgents() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + + // Two agents on the same channel + var connector1 = createConnector("slack", "C001", "tok", "sign", null); + var connector2 = createConnector("slack", "C001", "tok", "sign", null); + + var agent1 = new AgentConfiguration(); + agent1.setChannels(List.of(connector1)); + var agent2 = new AgentConfiguration(); + agent2.setChannels(List.of(connector2)); + + var status1 = createStatus("agent-aaa", 1); + var status2 = createStatus("agent-bbb", 1); + + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status1, status2)); + when(agentStore.read("agent-aaa", 1)).thenReturn(agent1); + when(agentStore.read("agent-bbb", 1)).thenReturn(agent2); + + var desc1 = new DocumentDescriptor(); + desc1.setName("Alpha Agent"); + when(descriptorStore.readDescriptor("agent-aaa", 1)).thenReturn(desc1); + + var desc2 = new DocumentDescriptor(); + desc2.setName("Beta Agent"); + when(descriptorStore.readDescriptor("agent-bbb", 1)).thenReturn(desc2); + + migration.runIfNeeded(); + + var captor = ArgumentCaptor.forClass(ChannelIntegrationConfiguration.class); + verify(channelStore, times(1)).create(captor.capture()); + var config = captor.getValue(); + + assertEquals(2, config.getTargets().size()); + // Sorted by agentId: agent-aaa first + assertEquals("alpha-agent", config.getTargets().get(0).getName()); + assertEquals("beta-agent", config.getTargets().get(1).getName()); + } + } + + // ─── Edge cases ─────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Edge cases") + class EdgeCases { + + @Test + @DisplayName("agent with null channels → skipped") + void nullChannels() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + + var agentConfig = new AgentConfiguration(); + agentConfig.setChannels(null); + + var status = createStatus("agent-1", 1); + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status)); + when(agentStore.read("agent-1", 1)).thenReturn(agentConfig); + + migration.runIfNeeded(); + + verify(channelStore, never()).create(any()); + verify(migrationLogStore).createMigrationLog(any()); + } + + @Test + @DisplayName("connector with null type → skipped") + void nullConnectorType() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + + var connector = new ChannelConnector(); + connector.setType(null); + connector.setConfig(Map.of("channelId", "C001")); + + var agentConfig = new AgentConfiguration(); + agentConfig.setChannels(List.of(connector)); + + var status = createStatus("agent-1", 1); + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status)); + when(agentStore.read("agent-1", 1)).thenReturn(agentConfig); + + migration.runIfNeeded(); + + verify(channelStore, never()).create(any()); + } + + @Test + @DisplayName("connector with blank channelId → skipped") + void blankChannelId() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + + var connector = new ChannelConnector(); + connector.setType(URI.create("slack")); + connector.setConfig(Map.of("channelId", " ")); + + var agentConfig = new AgentConfiguration(); + agentConfig.setChannels(List.of(connector)); + + var status = createStatus("agent-1", 1); + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status)); + when(agentStore.read("agent-1", 1)).thenReturn(agentConfig); + + migration.runIfNeeded(); + + verify(channelStore, never()).create(any()); + } + + @Test + @DisplayName("deployment with null agentId → skipped") + void nullAgentId() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + + var status = new DeploymentInfo(); + status.setDeploymentStatus(deployed); + status.setAgentId(null); + status.setAgentVersion(1); + + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status)); + + migration.runIfNeeded(); + + verify(agentStore, never()).read(any(), anyInt()); + } + + @Test + @DisplayName("deployment with null agentVersion → skipped") + void nullAgentVersion() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + + var status = new DeploymentInfo(); + status.setDeploymentStatus(deployed); + status.setAgentId("agent-1"); + status.setAgentVersion(null); + + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status)); + + migration.runIfNeeded(); + + verify(agentStore, never()).read(any(), anyInt()); + } + + @Test + @DisplayName("agent read throws → skipped with warning, other agents still processed") + void agentReadException() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + + var status1 = createStatus("agent-bad", 1); + var status2 = createStatus("agent-good", 1); + + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status1, status2)); + when(agentStore.read("agent-bad", 1)).thenThrow(new RuntimeException("corrupt")); + setupAgentConfig("agent-good", 1, "slack", "C002", "tok", "sign", null); + when(descriptorStore.readDescriptor("agent-good", 1)).thenReturn(null); + + migration.runIfNeeded(); + + verify(channelStore, times(1)).create(any()); + } + + @Test + @DisplayName("channelStore.create throws → does not set migration flag") + void createExceptionRetries() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + when(deploymentStore.readDeploymentInfos(deployed)) + .thenThrow(new RuntimeException("DB unavailable")); + + migration.runIfNeeded(); + + // Should NOT set the migration flag (so it retries on next startup) + verify(migrationLogStore, never()).createMigrationLog(any()); + } + } + + // ─── Slugify and reserved triggers ──────────────────────────────────────── + + @Nested + @DisplayName("Slugify and reserved triggers") + class SlugifyAndReserved { + + @Test + @DisplayName("emoji-only agent name → 'target' fallback") + void emojiOnlyName() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + setupDeployedAgent("agent-1", 1, "slack", "C001", "tok", "sign", null); + + var desc = new DocumentDescriptor(); + desc.setName("🤖💬"); + when(descriptorStore.readDescriptor("agent-1", 1)).thenReturn(desc); + + migration.runIfNeeded(); + + var captor = ArgumentCaptor.forClass(ChannelIntegrationConfiguration.class); + verify(channelStore).create(captor.capture()); + assertEquals("target", captor.getValue().getTargets().get(0).getName()); + } + + @Test + @DisplayName("agent named 'help' → trigger not assigned (reserved)") + void reservedTriggerName() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + setupDeployedAgent("agent-1", 1, "slack", "C001", "tok", "sign", null); + + var desc = new DocumentDescriptor(); + desc.setName("Help"); + when(descriptorStore.readDescriptor("agent-1", 1)).thenReturn(desc); + + migration.runIfNeeded(); + + var captor = ArgumentCaptor.forClass(ChannelIntegrationConfiguration.class); + verify(channelStore).create(captor.capture()); + var target = captor.getValue().getTargets().get(0); + assertEquals("help", target.getName()); + assertTrue(target.getTriggers().isEmpty(), + "'help' is a reserved keyword — migration should skip it as a trigger"); + } + + @Test + @DisplayName("duplicate slugified names get numeric suffix") + void duplicateNamesSuffix() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + + // Two agents with same name on same channel + var connector1 = createConnector("slack", "C001", "tok", "sign", null); + var connector2 = createConnector("slack", "C001", "tok", "sign", null); + + var agent1 = new AgentConfiguration(); + agent1.setChannels(List.of(connector1)); + var agent2 = new AgentConfiguration(); + agent2.setChannels(List.of(connector2)); + + var status1 = createStatus("agent-aaa", 1); + var status2 = createStatus("agent-bbb", 1); + + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status1, status2)); + when(agentStore.read("agent-aaa", 1)).thenReturn(agent1); + when(agentStore.read("agent-bbb", 1)).thenReturn(agent2); + + // Both agents have same name + var desc = new DocumentDescriptor(); + desc.setName("Support Bot"); + when(descriptorStore.readDescriptor(anyString(), anyInt())).thenReturn(desc); + + migration.runIfNeeded(); + + var captor = ArgumentCaptor.forClass(ChannelIntegrationConfiguration.class); + verify(channelStore).create(captor.capture()); + var targets = captor.getValue().getTargets(); + assertEquals(2, targets.size()); + assertEquals("support-bot", targets.get(0).getName()); + assertEquals("support-bot-2", targets.get(1).getName()); + } + } + + // ─── Credential divergence warning ──────────────────────────────────────── + + @Nested + @DisplayName("Credential divergence") + class CredentialDivergence { + + @Test + @DisplayName("agents with different credentials → migration still succeeds (warn only)") + void divergentCredentials() throws Exception { + when(migrationLogStore.readMigrationLog(anyString())).thenReturn(null); + + var connector1 = createConnector("slack", "C001", "tok-A", "sign-A", null); + var connector2 = createConnector("slack", "C001", "tok-B", "sign-B", null); + + var agent1 = new AgentConfiguration(); + agent1.setChannels(List.of(connector1)); + var agent2 = new AgentConfiguration(); + agent2.setChannels(List.of(connector2)); + + var status1 = createStatus("agent-aaa", 1); + var status2 = createStatus("agent-bbb", 1); + + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status1, status2)); + when(agentStore.read("agent-aaa", 1)).thenReturn(agent1); + when(agentStore.read("agent-bbb", 1)).thenReturn(agent2); + when(descriptorStore.readDescriptor(anyString(), anyInt())).thenReturn(null); + + migration.runIfNeeded(); + + // Should still create the config (using first agent's credentials) + verify(channelStore, times(1)).create(any()); + verify(migrationLogStore).createMigrationLog(any()); + } + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private void setupDeployedAgent(String agentId, int version, String channelType, + String channelId, String botToken, String signingSecret, + String groupId) + throws Exception { + var status = createStatus(agentId, version); + when(deploymentStore.readDeploymentInfos(deployed)).thenReturn(List.of(status)); + setupAgentConfig(agentId, version, channelType, channelId, botToken, signingSecret, groupId); + } + + private void setupAgentConfig(String agentId, int version, String channelType, + String channelId, String botToken, String signingSecret, + String groupId) + throws Exception { + var connector = createConnector(channelType, channelId, botToken, signingSecret, groupId); + var agentConfig = new AgentConfiguration(); + agentConfig.setChannels(List.of(connector)); + when(agentStore.read(agentId, version)).thenReturn(agentConfig); + } + + private ChannelConnector createConnector(String type, String channelId, + String botToken, String signingSecret, String groupId) { + var connector = new ChannelConnector(); + connector.setType(URI.create(type)); + var config = new HashMap(); + config.put("channelId", channelId); + config.put("botToken", botToken); + config.put("signingSecret", signingSecret); + if (groupId != null) { + config.put("groupId", groupId); + } + connector.setConfig(config); + return connector; + } + + private DeploymentInfo createStatus(String agentId, int version) { + var status = new DeploymentInfo(); + status.setDeploymentStatus(deployed); + status.setAgentId(agentId); + status.setAgentVersion(version); + return status; + } +} diff --git a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java index abb0524b9..c826ec2f7 100644 --- a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java @@ -608,6 +608,181 @@ void withGroupId() { } } + // ─── resolveThreadTarget with legacy credentials ────────────────────────── + + @Nested + @DisplayName("resolveThreadTarget with legacy credentials") + class ThreadTargetLegacyCredentials { + + @Test + @DisplayName("thread target attaches legacy credentials when no new-style config exists") + void legacyCredentialsAttached() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + setupLegacyAgent(AGENT_ID, CHANNEL_ID, "xoxb-leg", "sign-leg", null); + when(secretResolver.resolveValue(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + // Force refresh + router.hasAnyChannels("slack"); + + // Lock a thread target + var target = new ChannelTarget(); + target.setName("locked-target"); + router.lockThreadTarget("slack", CHANNEL_ID, "thread-1", target); + + ResolvedTarget result = router.resolveThreadTarget("slack", CHANNEL_ID, "thread-1"); + + assertNotNull(result); + assertEquals("locked-target", result.target().getName()); + assertEquals("xoxb-leg", result.legacyBotToken()); + assertEquals("sign-leg", result.legacySigningSecret()); + } + + @Test + @DisplayName("thread target has null legacy credentials when new-style config exists") + void noLegacyWhenNewStyleExists() throws Exception { + setupNewStyleConfig(CHANNEL_ID, "xoxb-new", "sign-new"); + + // Lock a thread target + var target = new ChannelTarget(); + target.setName("locked-target"); + router.lockThreadTarget("slack", CHANNEL_ID, "thread-1", target); + + ResolvedTarget result = router.resolveThreadTarget("slack", CHANNEL_ID, "thread-1"); + + assertNotNull(result); + assertNull(result.legacyBotToken()); + assertNull(result.legacySigningSecret()); + assertNotNull(result.integration()); + } + } + + // ─── Refresh edge cases for legacy agents ───────────────────────────────── + + @Nested + @DisplayName("Legacy refresh edge cases") + class LegacyRefreshEdgeCases { + + @Test + @DisplayName("agent with deleted descriptor → skipped") + void deletedDescriptorSkipped() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + + var desc = new DocumentDescriptor(); + desc.setDeleted(true); + + var status = new AgentDeploymentStatus( + Deployment.Environment.production, AGENT_ID, 1, + Deployment.Status.READY, desc); + when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) + .thenReturn(List.of(status)); + + assertNull(router.resolveTarget("slack", CHANNEL_ID, "hello")); + } + + @Test + @DisplayName("agent with null descriptor → skipped") + void nullDescriptorSkipped() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + + var status = new AgentDeploymentStatus( + Deployment.Environment.production, AGENT_ID, 1, + Deployment.Status.READY, null); + when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) + .thenReturn(List.of(status)); + + assertNull(router.resolveTarget("slack", CHANNEL_ID, "hello")); + } + + @Test + @DisplayName("agent with non-slack connector type → skipped in legacy path") + void nonSlackConnectorSkipped() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + + var connector = new ChannelConnector(); + connector.setType(URI.create("teams")); // not slack + connector.setConfig(Map.of("channelId", CHANNEL_ID, "botToken", "tok")); + + var agentConfig = new AgentConfiguration(); + agentConfig.setChannels(List.of(connector)); + + var desc = new DocumentDescriptor(); + desc.setDeleted(false); + var status = new AgentDeploymentStatus( + Deployment.Environment.production, AGENT_ID, 1, + Deployment.Status.READY, desc); + when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) + .thenReturn(List.of(status)); + when(agentStore.readAgent(eq(AGENT_ID), eq(1))).thenReturn(agentConfig); + + assertNull(router.resolveTarget("slack", CHANNEL_ID, "hello")); + assertFalse(router.hasAnyChannels("slack")); + } + + @Test + @DisplayName("connector with null config → skipped") + void connectorNullConfigSkipped() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + + var connector = new ChannelConnector(); + connector.setType(URI.create("slack")); + connector.setConfig(null); + + var agentConfig = new AgentConfiguration(); + agentConfig.setChannels(List.of(connector)); + + var desc = new DocumentDescriptor(); + desc.setDeleted(false); + var status = new AgentDeploymentStatus( + Deployment.Environment.production, AGENT_ID, 1, + Deployment.Status.READY, desc); + when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) + .thenReturn(List.of(status)); + when(agentStore.readAgent(eq(AGENT_ID), eq(1))).thenReturn(agentConfig); + + assertNull(router.resolveTarget("slack", CHANNEL_ID, "hello")); + } + + @Test + @DisplayName("legacy agent with blank groupId → AGENT type (not GROUP)") + void blankGroupIdIsAgent() throws Exception { + when(descriptorStore.readDescriptors(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())) + .thenReturn(List.of()); + setupLegacyAgent(AGENT_ID, CHANNEL_ID, "xoxb-tok", "sign-sec", null); + // The helper passes null for groupId, so set up an agent with blank groupId + // explicitly + var connector = new ChannelConnector(); + connector.setType(URI.create("slack")); + var cfg = new HashMap(); + cfg.put("channelId", CHANNEL_ID); + cfg.put("botToken", "xoxb-tok"); + cfg.put("signingSecret", "sign-sec"); + cfg.put("groupId", " "); // blank, not null + connector.setConfig(cfg); + + var agentConfig = new AgentConfiguration(); + agentConfig.setChannels(List.of(connector)); + + var desc = new DocumentDescriptor(); + desc.setDeleted(false); + var status = new AgentDeploymentStatus( + Deployment.Environment.production, AGENT_ID, 1, + Deployment.Status.READY, desc); + when(agentAdmin.getDeploymentStatuses(Deployment.Environment.production)) + .thenReturn(List.of(status)); + when(agentStore.readAgent(eq(AGENT_ID), eq(1))).thenReturn(agentConfig); + when(secretResolver.resolveValue(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + ResolvedTarget result = router.resolveTarget("slack", CHANNEL_ID, "hello"); + assertNotNull(result); + assertEquals(ChannelTarget.TargetType.AGENT, result.target().getType()); + } + } + // ─── getBotToken ─────────────────────────────────────────────────────────── @Nested diff --git a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java index d51290a0c..a98c2daa7 100644 --- a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java @@ -13,6 +13,7 @@ import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @@ -278,7 +279,7 @@ void lockedTargetReturned() { target.setName("architect"); target.setTargetId("agent-arch-id"); - router.lockThreadTarget("1713400000.123456", target); + router.lockThreadTarget("slack", "C07TEST", "1713400000.123456", target); ResolvedTarget result = router.resolveThreadTarget("slack", "C07TEST", "1713400000.123456"); @@ -305,8 +306,8 @@ void independentThreadLocks() { var sec = new ChannelTarget(); sec.setName("security"); - router.lockThreadTarget("thread-1", arch); - router.lockThreadTarget("thread-2", sec); + router.lockThreadTarget("slack", "C07TEST", "thread-1", arch); + router.lockThreadTarget("slack", "C07TEST", "thread-2", sec); assertEquals("architect", router.resolveThreadTarget("slack", "C07TEST", "thread-1") @@ -376,6 +377,336 @@ void singleTarget() { assertEquals("support", result.target().getName()); assertEquals("I need help with my order", result.strippedMessage()); } + + @Test + @DisplayName("integration with no default target → returns null for unmatched message") + void noDefaultTarget() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setDefaultTargetName("nonexistent"); + var t = new ChannelTarget(); + t.setName("only"); + t.setTriggers(List.of("only")); + t.setTargetId("agent-x"); + cfg.setTargets(List.of(t)); + + ResolvedTarget result = router.resolveFromIntegration(cfg, "unmatched message"); + assertNull(result); + } + + @Test + @DisplayName("integration with null defaultTargetName → returns null for unmatched message") + void nullDefaultTargetName() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setDefaultTargetName(null); + var t = new ChannelTarget(); + t.setName("only"); + t.setTriggers(List.of("only")); + t.setTargetId("agent-x"); + cfg.setTargets(List.of(t)); + + ResolvedTarget result = router.resolveFromIntegration(cfg, "unmatched message"); + assertNull(result); + } + + @Test + @DisplayName("target with null triggers list → skipped, falls to default") + void targetWithNullTriggersList() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setDefaultTargetName("default-target"); + + var noTriggers = new ChannelTarget(); + noTriggers.setName("no-triggers"); + noTriggers.setTriggers(null); + noTriggers.setTargetId("agent-1"); + + var defaultTarget = new ChannelTarget(); + defaultTarget.setName("default-target"); + defaultTarget.setTriggers(List.of("dt")); + defaultTarget.setTargetId("agent-2"); + + cfg.setTargets(List.of(noTriggers, defaultTarget)); + + ResolvedTarget result = router.resolveFromIntegration(cfg, "something: message"); + assertNotNull(result); + assertEquals("default-target", result.target().getName()); + } + + @Test + @DisplayName("target with null trigger entry → skipped") + void targetWithNullTriggerEntry() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setDefaultTargetName("t1"); + + var t1 = new ChannelTarget(); + t1.setName("t1"); + var triggers = new java.util.ArrayList(); + triggers.add(null); + triggers.add("real"); + t1.setTriggers(triggers); + t1.setTargetId("agent-1"); + cfg.setTargets(List.of(t1)); + + // "real:" should still match even though there's a null in the trigger list + ResolvedTarget result = router.resolveFromIntegration(cfg, "real: hello"); + assertNotNull(result); + assertEquals("t1", result.target().getName()); + assertEquals("hello", result.strippedMessage()); + } + } + + // ─── ResolvedTarget credential resolution ───────────────────────────────── + + @Nested + @DisplayName("ResolvedTarget credential resolution") + class ResolvedTargetCredentials { + + @Test + @DisplayName("botToken from integration platformConfig") + void botTokenFromIntegration() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setPlatformConfig(Map.of("botToken", "xoxb-integration-token")); + + var target = new ChannelTarget(); + var resolved = new ResolvedTarget(target, "msg", cfg, null, null); + + assertEquals("xoxb-integration-token", resolved.botToken()); + } + + @Test + @DisplayName("signingSecret from integration platformConfig") + void signingSecretFromIntegration() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setPlatformConfig(Map.of("signingSecret", "secret-from-integration")); + + var target = new ChannelTarget(); + var resolved = new ResolvedTarget(target, "msg", cfg, null, null); + + assertEquals("secret-from-integration", resolved.signingSecret()); + } + + @Test + @DisplayName("botToken falls back to legacy when integration is null") + void botTokenFallsBackToLegacy() { + var target = new ChannelTarget(); + var resolved = new ResolvedTarget(target, "msg", null, + "xoxb-legacy-token", "legacy-secret"); + + assertEquals("xoxb-legacy-token", resolved.botToken()); + } + + @Test + @DisplayName("signingSecret falls back to legacy when integration is null") + void signingSecretFallsBackToLegacy() { + var target = new ChannelTarget(); + var resolved = new ResolvedTarget(target, "msg", null, + "xoxb-legacy", "legacy-signing-secret"); + + assertEquals("legacy-signing-secret", resolved.signingSecret()); + } + + @Test + @DisplayName("botToken returns null when integration has no platformConfig") + void botTokenNullWhenNoPlatformConfig() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setPlatformConfig(null); + + var target = new ChannelTarget(); + var resolved = new ResolvedTarget(target, "msg", cfg, null, null); + + assertNull(resolved.botToken()); + } + + @Test + @DisplayName("signingSecret returns null when integration has no platformConfig") + void signingSecretNullWhenNoPlatformConfig() { + var cfg = new ChannelIntegrationConfiguration(); + cfg.setPlatformConfig(null); + + var target = new ChannelTarget(); + var resolved = new ResolvedTarget(target, "msg", cfg, null, null); + + assertNull(resolved.signingSecret()); + } + + @Test + @DisplayName("botToken returns null when both integration and legacy are null") + void botTokenNullWhenBothNull() { + var target = new ChannelTarget(); + var resolved = new ResolvedTarget(target, "msg", null, null, null); + + assertNull(resolved.botToken()); + } + } + + // ─── LegacyTarget ───────────────────────────────────────────────────────── + + @Nested + @DisplayName("LegacyTarget") + class LegacyTargetTests { + + @Test + @DisplayName("toChannelTarget with agentId → AGENT type") + void toChannelTargetAgent() { + var legacy = new ChannelTargetRouter.LegacyTarget("agent-123", "token", "secret", null); + var target = legacy.toChannelTarget(); + + assertEquals("default", target.getName()); + assertEquals(ChannelTarget.TargetType.AGENT, target.getType()); + assertEquals("agent-123", target.getTargetId()); + } + + @Test + @DisplayName("toChannelTarget with groupId → GROUP type") + void toChannelTargetGroup() { + var legacy = new ChannelTargetRouter.LegacyTarget("agent-123", "token", "secret", "group-456"); + var target = legacy.toChannelTarget(); + + assertEquals("default", target.getName()); + assertEquals(ChannelTarget.TargetType.GROUP, target.getType()); + assertEquals("group-456", target.getTargetId()); + } + } + + // ─── Thread lock composite key isolation ─────────────────────────────────── + + @Nested + @DisplayName("Thread lock composite key isolation") + class ThreadLockIsolation { + + @Test + @DisplayName("same threadTs in different channels → independent locks") + void crossChannelIsolation() { + var target1 = new ChannelTarget(); + target1.setName("target-channel-1"); + + var target2 = new ChannelTarget(); + target2.setName("target-channel-2"); + + // Lock same threadTs but in different channels + router.lockThreadTarget("slack", "C001", "thread-abc", target1); + router.lockThreadTarget("slack", "C002", "thread-abc", target2); + + var result1 = router.resolveThreadTarget("slack", "C001", "thread-abc"); + var result2 = router.resolveThreadTarget("slack", "C002", "thread-abc"); + + assertNotNull(result1); + assertNotNull(result2); + assertEquals("target-channel-1", result1.target().getName()); + assertEquals("target-channel-2", result2.target().getName()); + } + + @Test + @DisplayName("same threadTs in different platform types → independent locks") + void crossPlatformIsolation() { + var slackTarget = new ChannelTarget(); + slackTarget.setName("slack-target"); + + var teamsTarget = new ChannelTarget(); + teamsTarget.setName("teams-target"); + + router.lockThreadTarget("slack", "C001", "thread-1", slackTarget); + router.lockThreadTarget("teams", "C001", "thread-1", teamsTarget); + + var slackResult = router.resolveThreadTarget("slack", "C001", "thread-1"); + var teamsResult = router.resolveThreadTarget("teams", "C001", "thread-1"); + + assertNotNull(slackResult); + assertNotNull(teamsResult); + assertEquals("slack-target", slackResult.target().getName()); + assertEquals("teams-target", teamsResult.target().getName()); + } + + @Test + @DisplayName("channelType normalization in lock/resolve — SLACK matches slack") + void channelTypeNormalization() { + var target = new ChannelTarget(); + target.setName("test-target"); + + // Lock with mixed case + router.lockThreadTarget("SLACK", "C001", "thread-1", target); + + // Resolve with lowercase + var result = router.resolveThreadTarget("slack", "C001", "thread-1"); + assertNotNull(result); + assertEquals("test-target", result.target().getName()); + } + + @Test + @DisplayName("null channelType is handled safely in lockThreadTarget") + void nullChannelTypeInLock() { + var target = new ChannelTarget(); + target.setName("test"); + assertDoesNotThrow(() -> router.lockThreadTarget(null, "C001", "t1", target)); + } + } + + // ─── Locale-safe API normalization ───────────────────────────────────────── + + @Nested + @DisplayName("Locale-safe channel type normalization") + class LocaleNormalization { + + @Test + @DisplayName("getSigningSecrets with mixed case → normalizes to slack") + void getSigningSecretsCaseInsensitive() { + // Without integration data loaded, returns empty set for any type + Set secrets = router.getSigningSecrets("SLACK"); + assertNotNull(secrets); + assertTrue(secrets.isEmpty()); + } + + @Test + @DisplayName("getSigningSecrets with null channelType → empty set") + void getSigningSecretsNull() { + assertDoesNotThrow(() -> { + Set secrets = router.getSigningSecrets(null); + assertTrue(secrets.isEmpty()); + }); + } + + @Test + @DisplayName("getIntegration with null channelType → empty Optional") + void getIntegrationNullType() { + assertDoesNotThrow(() -> { + assertTrue(router.getIntegration(null, "C001").isEmpty()); + }); + } + + @Test + @DisplayName("getBotToken with null channelType → null") + void getBotTokenNullType() { + assertDoesNotThrow(() -> { + assertNull(router.getBotToken(null, "C001")); + }); + } + + @Test + @DisplayName("hasAnyChannels with null channelType → false") + void hasAnyChannelsNullType() { + assertDoesNotThrow(() -> { + assertFalse(router.hasAnyChannels(null)); + }); + } + + @Test + @DisplayName("resolveTarget with null channelType → null (no match)") + void resolveTargetNullType() { + assertDoesNotThrow(() -> { + assertNull(router.resolveTarget(null, "C001", "hello")); + }); + } + + @Test + @DisplayName("resolveTarget with non-slack type → null (no match)") + void resolveTargetUnknownType() { + assertNull(router.resolveTarget("teams", "C001", "hello")); + } + + @Test + @DisplayName("hasAnyChannels with unknown non-slack type → false") + void hasAnyChannelsUnknown() { + assertFalse(router.hasAnyChannels("teams")); + } } // ─── Test helper: simple ConcurrentHashMap-based ICache ───── diff --git a/src/test/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhookTest.java b/src/test/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhookTest.java new file mode 100644 index 000000000..0c6d78300 --- /dev/null +++ b/src/test/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhookTest.java @@ -0,0 +1,190 @@ +package ai.labs.eddi.integrations.slack.rest; + +import ai.labs.eddi.integrations.channels.ChannelTargetRouter; +import ai.labs.eddi.integrations.slack.SlackEventHandler; +import ai.labs.eddi.integrations.slack.SlackIntegrationConfig; +import ai.labs.eddi.integrations.slack.SlackSignatureVerifier; +import com.fasterxml.jackson.databind.ObjectMapper; +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 java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link RestSlackWebhook}. Covers signature verification, URL + * verification challenge, event dispatching, and disabled/error paths. + */ +class RestSlackWebhookTest { + + private SlackIntegrationConfig config; + private ChannelTargetRouter channelTargetRouter; + private SlackSignatureVerifier signatureVerifier; + private SlackEventHandler eventHandler; + private ObjectMapper objectMapper; + private RestSlackWebhook webhook; + + @BeforeEach + void setUp() { + config = mock(SlackIntegrationConfig.class); + channelTargetRouter = mock(ChannelTargetRouter.class); + signatureVerifier = mock(SlackSignatureVerifier.class); + eventHandler = mock(SlackEventHandler.class); + objectMapper = new ObjectMapper(); + + webhook = new RestSlackWebhook(config, channelTargetRouter, signatureVerifier, + eventHandler, objectMapper); + } + + // ─── Disabled ────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Disabled integration") + class Disabled { + + @Test + @DisplayName("returns 404 when slack integration is disabled") + void returns404WhenDisabled() { + when(config.enabled()).thenReturn(false); + + Response response = webhook.handleEvents("{}", "sig", "ts"); + + assertEquals(404, response.getStatus()); + verifyNoInteractions(signatureVerifier, eventHandler); + } + } + + // ─── Signature verification ─────────────────────────────────────────────── + + @Nested + @DisplayName("Signature verification") + class SignatureVerification { + + @Test + @DisplayName("returns 403 when signature verification fails") + void returns403OnBadSignature() { + when(config.enabled()).thenReturn(true); + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(eq("ts"), eq("{}"), eq("bad-sig"), any())) + .thenReturn(false); + + Response response = webhook.handleEvents("{}", "bad-sig", "ts"); + + assertEquals(403, response.getStatus()); + verifyNoInteractions(eventHandler); + } + } + + // ─── URL verification ───────────────────────────────────────────────────── + + @Nested + @DisplayName("URL verification challenge") + class UrlVerification { + + @Test + @DisplayName("echoes challenge for url_verification type") + void echoesChallenge() { + when(config.enabled()).thenReturn(true); + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); + + String body = "{\"type\":\"url_verification\",\"challenge\":\"abc123\"}"; + + Response response = webhook.handleEvents(body, "sig", "ts"); + + assertEquals(200, response.getStatus()); + String entity = (String) response.getEntity(); + assertTrue(entity.contains("abc123")); + } + + @Test + @DisplayName("handles null challenge gracefully") + void handleNullChallenge() { + when(config.enabled()).thenReturn(true); + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); + + String body = "{\"type\":\"url_verification\"}"; + + Response response = webhook.handleEvents(body, "sig", "ts"); + + assertEquals(200, response.getStatus()); + } + } + + // ─── Event callback ─────────────────────────────────────────────────────── + + @Nested + @DisplayName("Event callback") + class EventCallback { + + @Test + @DisplayName("delegates event_callback to eventHandler") + void delegatesEventCallback() { + when(config.enabled()).thenReturn(true); + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); + + String body = "{\"type\":\"event_callback\",\"event_id\":\"evt-1\",\"event\":{\"type\":\"message\",\"text\":\"hello\"}}"; + + Response response = webhook.handleEvents(body, "sig", "ts"); + + assertEquals(200, response.getStatus()); + verify(eventHandler).handleEventAsync(eq("evt-1"), any()); + } + + @Test + @DisplayName("returns 200 even when event is null") + void nullEvent() { + when(config.enabled()).thenReturn(true); + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); + + String body = "{\"type\":\"event_callback\",\"event_id\":\"evt-1\"}"; + + Response response = webhook.handleEvents(body, "sig", "ts"); + + assertEquals(200, response.getStatus()); + verifyNoInteractions(eventHandler); + } + + @Test + @DisplayName("unknown type returns 200 (Slack expects it)") + void unknownType() { + when(config.enabled()).thenReturn(true); + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); + + String body = "{\"type\":\"something_else\"}"; + + Response response = webhook.handleEvents(body, "sig", "ts"); + + assertEquals(200, response.getStatus()); + } + } + + // ─── Malformed payload ──────────────────────────────────────────────────── + + @Nested + @DisplayName("Malformed payload") + class MalformedPayload { + + @Test + @DisplayName("returns 400 for invalid JSON") + void invalidJson() { + when(config.enabled()).thenReturn(true); + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); + + Response response = webhook.handleEvents("not json", "sig", "ts"); + + assertEquals(400, response.getStatus()); + } + } +} From 0537232700175521206562b86c8c4bc4f3882023 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 25 Apr 2026 11:30:22 +0200 Subject: [PATCH 22/35] fix(channels): reject duplicate target names in validation Duplicate target names (case-insensitive) caused findDefaultTarget() to silently pick the wrong target via findFirst(). Added usedNames check in validateConfiguration() and corresponding test case. --- .../rest/RestChannelIntegrationStore.java | 8 ++++++- ...ChannelIntegrationStoreValidationTest.java | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java index 761df06c1..20f0c0ce8 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java @@ -169,12 +169,18 @@ void validateConfiguration(ChannelIntegrationConfiguration config) { + "' does not match any target name."); } - // No duplicate trigger keywords across targets + // No duplicate target names or trigger keywords across targets + Set usedNames = new HashSet<>(); Set allTriggers = new HashSet<>(); for (ChannelTarget target : targets) { if (target.getName() == null || target.getName().isBlank()) { throw new BadRequestException("Every target must have a name."); } + if (!usedNames.add(target.getName().toLowerCase(Locale.ROOT))) { + throw new BadRequestException( + "Duplicate target name: '" + target.getName() + + "'. Each target must have a unique name."); + } if (target.getTargetId() == null || target.getTargetId().isBlank()) { throw new BadRequestException( "Target '" + target.getName() + "' must have a targetId."); diff --git a/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java index f3119c790..24c72cb3f 100644 --- a/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java +++ b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java @@ -171,6 +171,27 @@ void targetNullTargetId() { () -> store.validateConfiguration(config)); assertTrue(ex.getMessage().contains("targetId")); } + + @Test + @DisplayName("duplicate target names (case-insensitive) → BadRequest") + void duplicateTargetNames() { + var t1 = new ChannelTarget(); + t1.setName("Support"); + t1.setTargetId("agent-1"); + t1.setTriggers(List.of("assist")); + + var t2 = new ChannelTarget(); + t2.setName("support"); // same name, different case + t2.setTargetId("agent-2"); + t2.setTriggers(List.of("review")); + + config.setTargets(List.of(t1, t2)); + config.setDefaultTargetName("Support"); + + var ex = assertThrows(BadRequestException.class, + () -> store.validateConfiguration(config)); + assertTrue(ex.getMessage().contains("Duplicate target name")); + } } // ─── Default target ──────────────────────────────────────────────────────── From dd9a6fd56c1184d725ddccdef27bb0a7630ae935 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 25 Apr 2026 12:41:01 +0200 Subject: [PATCH 23/35] =?UTF-8?q?fix(channels):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20defensive=20copies,=20migration=20retry,=20null=20g?= =?UTF-8?q?uards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelIntegrationConfiguration: defensive copying in getters/setters to prevent internal representation exposure (getPlatformConfig, getTargets now return copies; setters handle null safely) - ChannelConnectorMigration: track per-channel create failures and skip migration flag write when any failed, ensuring retry on next startup - ChannelTargetRouter: null-guard on getTargets() in resolveFromIntegration and findDefaultTarget to prevent NPE from configs that bypassed REST validation (e.g. direct DB writes, older records) --- .../model/ChannelIntegrationConfiguration.java | 8 ++++---- .../migration/ChannelConnectorMigration.java | 18 ++++++++++++++---- .../channels/ChannelTargetRouter.java | 17 ++++++++++------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java index 0a4c4a3d3..409c766a2 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java @@ -72,11 +72,11 @@ public void setChannelType(String channelType) { * Secret values should use vault references: {@code ${eddivault:key-name}}. */ public Map getPlatformConfig() { - return platformConfig; + return new HashMap<>(platformConfig); } public void setPlatformConfig(Map platformConfig) { - this.platformConfig = platformConfig; + this.platformConfig = platformConfig == null ? new HashMap<>() : new HashMap<>(platformConfig); } /** @@ -84,11 +84,11 @@ public void setPlatformConfig(Map platformConfig) { * agent or group. At least one target is required. */ public List getTargets() { - return targets; + return new ArrayList<>(targets); } public void setTargets(List targets) { - this.targets = targets; + this.targets = targets == null ? new ArrayList<>() : new ArrayList<>(targets); } /** diff --git a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java index a5eb7d39e..502c9cc90 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java @@ -76,8 +76,16 @@ public void runIfNeeded() { LOGGER.info("Starting channel connector migration..."); try { - int migrated = migrateConnectors(); - LOGGER.infof("Channel connector migration complete: %d configs created", migrated); + int[] result = migrateConnectors(); // [created, failed] + int created = result[0]; + int failed = result[1]; + LOGGER.infof("Channel connector migration complete: %d configs created, %d failed", created, failed); + + if (failed > 0) { + LOGGER.errorf("Channel connector migration had %d failure(s) — " + + "will retry on next startup. Check WARN logs above for details.", failed); + return; // Don't set flag so it retries + } } catch (Exception e) { LOGGER.error("Channel connector migration failed — will retry on next startup", e); return; // Don't set flag so it retries @@ -86,7 +94,7 @@ public void runIfNeeded() { migrationLogStore.createMigrationLog(new MigrationLog(MIGRATION_KEY)); } - private int migrateConnectors() { + private int[] migrateConnectors() { // Group connectors by channelType:channelId var channelGroups = new LinkedHashMap>(); @@ -126,6 +134,7 @@ private int migrateConnectors() { } int created = 0; + int failed = 0; for (var entry : channelGroups.entrySet()) { var entries = entry.getValue(); // Sort for deterministic default target @@ -192,11 +201,12 @@ private int migrateConnectors() { created++; LOGGER.infof(" Migrated channel %s:%s (%d targets)", channelType, channelId, targets.size()); } catch (Exception e) { + failed++; LOGGER.warnf(" Failed to create config for %s:%s — %s", channelType, channelId, e.getMessage()); } } - return created; + return new int[]{created, failed}; } private record ConnectorEntry(ChannelConnector connector, String agentId, diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index 03485861f..d2235cf42 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -279,12 +279,15 @@ ResolvedTarget resolveFromIntegration(ChannelIntegrationConfiguration integratio String candidateTrigger = trimmed.substring(0, colonIdx).trim().toLowerCase(Locale.ROOT); String remainder = trimmed.substring(colonIdx + 1).trim(); - for (ChannelTarget target : integration.getTargets()) { - if (target.getTriggers() != null) { - for (String trigger : target.getTriggers()) { - if (trigger != null && trigger.toLowerCase(Locale.ROOT).trim().equals(candidateTrigger)) { - return new ResolvedTarget(target, remainder, integration, - null, null); + var targets = integration.getTargets(); + if (targets != null) { + for (ChannelTarget target : targets) { + if (target.getTriggers() != null) { + for (String trigger : target.getTriggers()) { + if (trigger != null && trigger.toLowerCase(Locale.ROOT).trim().equals(candidateTrigger)) { + return new ResolvedTarget(target, remainder, integration, + null, null); + } } } } @@ -303,7 +306,7 @@ ResolvedTarget resolveFromIntegration(ChannelIntegrationConfiguration integratio private ChannelTarget findDefaultTarget(ChannelIntegrationConfiguration integration) { String defaultName = integration.getDefaultTargetName(); - if (defaultName == null) + if (defaultName == null || integration.getTargets() == null) return null; return integration.getTargets().stream() .filter(t -> t.getName() != null From 67ab9ba5c824ab6fb453f00e254f45cd7e121ee1 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 25 Apr 2026 12:52:53 +0200 Subject: [PATCH 24/35] fix(channels): fix duplicateChannel regression from defensive copying getPlatformConfig() now returns a defensive copy, so the previous remove('channelId') call was silently operating on a throwaway copy. Fix: get the copy, remove channelId, then set it back via setter. --- .../configs/channels/rest/RestChannelIntegrationStore.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java index 20f0c0ce8..4a73c4c87 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java @@ -98,7 +98,9 @@ public Response duplicateChannel(String id, Integer version) { // Clear channelId so the duplicate doesn't collide in the router's // integrationMap (each channelType:channelId must be unique) if (config.getPlatformConfig() != null) { - config.getPlatformConfig().remove("channelId"); + var platformConfig = config.getPlatformConfig(); + platformConfig.remove("channelId"); + config.setPlatformConfig(platformConfig); } validateConfiguration(config); Response response = restVersionInfo.create(config); From 95f2759832f4e3e04c76967b039d76bb6ecadeee Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sat, 25 Apr 2026 14:11:24 +0200 Subject: [PATCH 25/35] fix(ci): harden Postgres integration tests against CI flakiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-pronged fix for randomly failing Postgres ITs in GitHub Actions: 1. Increase Testcontainers startup timeout from 120s to 180s for both MongoDB and Postgres container ITs. CI runners are resource-constrained and EDDI with Postgres schema creation needs the headroom. 2. Add quarkus.mongodb.health.enabled=false to PostgresIntegrationTestProfile. Without this, the MongoDB health check still runs in the Postgres profile and returns DOWN (no MongoDB instance), dragging the readiness probe to 503. 3. Add rerunFailingTestsCount=1 to maven-failsafe-plugin. Testcontainers ITs can flake on CI due to Docker networking timing — this gives one automatic retry before failing the build. --- pom.xml | 4 ++++ src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java | 2 +- .../labs/eddi/integration/PostgresIntegrationTestProfile.java | 3 ++- .../eddi/integration/postgres/PostgresAgentUseCaseIT.java | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 976f8395c..d7b0d2f09 100644 --- a/pom.xml +++ b/pom.xml @@ -544,6 +544,10 @@ Prevents CI from hanging indefinitely when Docker image builds or container startup fails to propagate errors. --> 900 + + 1 ${project.build.directory}/${project.build.finalName}-runner diff --git a/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java b/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java index c458b4293..a0ec60a9e 100644 --- a/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java +++ b/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java @@ -59,7 +59,7 @@ public abstract class ContainerBaseIT extends BaseIntegrationIT { .dependsOn(MONGO) .waitingFor(Wait.forHttp("/q/health/ready") .forPort(7070) - .withStartupTimeout(Duration.ofSeconds(120))); + .withStartupTimeout(Duration.ofSeconds(180))); /** * Builds the EDDI Docker image using a test-specific inline Dockerfile with diff --git a/src/test/java/ai/labs/eddi/integration/PostgresIntegrationTestProfile.java b/src/test/java/ai/labs/eddi/integration/PostgresIntegrationTestProfile.java index 1ab95b1d8..25149c409 100644 --- a/src/test/java/ai/labs/eddi/integration/PostgresIntegrationTestProfile.java +++ b/src/test/java/ai/labs/eddi/integration/PostgresIntegrationTestProfile.java @@ -26,8 +26,9 @@ public Map getConfigOverrides() { Map.entry("quarkus.datasource.db-kind", "postgresql"), Map.entry("quarkus.datasource.active", "true"), Map.entry("quarkus.datasource.devservices.enabled", "true"), - // Disable MongoDB + // Disable MongoDB — both DevServices and health check Map.entry("quarkus.mongodb.devservices.enabled", "false"), + Map.entry("quarkus.mongodb.health.enabled", "false"), // Auth disabled Map.entry("quarkus.oidc.tenant-enabled", "false"), Map.entry("authorization.enabled", "false"), diff --git a/src/test/java/ai/labs/eddi/integration/postgres/PostgresAgentUseCaseIT.java b/src/test/java/ai/labs/eddi/integration/postgres/PostgresAgentUseCaseIT.java index ef27145b6..a70a70944 100644 --- a/src/test/java/ai/labs/eddi/integration/postgres/PostgresAgentUseCaseIT.java +++ b/src/test/java/ai/labs/eddi/integration/postgres/PostgresAgentUseCaseIT.java @@ -72,7 +72,7 @@ public class PostgresAgentUseCaseIT extends BaseIntegrationIT { new org.testcontainers.containers.output.Slf4jLogConsumer(org.slf4j.LoggerFactory.getLogger(PostgresAgentUseCaseIT.class))) .waitingFor(Wait.forHttp("/q/health/ready") .forPort(7070) - .withStartupTimeout(Duration.ofSeconds(120))); + .withStartupTimeout(Duration.ofSeconds(180))); @BeforeAll static void configureRestAssured() { From 94d6d0fb9bb5e664d90b2397d90a287144154bd0 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 26 Apr 2026 12:54:23 +0200 Subject: [PATCH 26/35] fix(migration): inject IMigrationLogStore interface instead of concrete MongoDB class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChannelConnectorMigration, V6RenameMigration, and V6QuteMigration all injected the concrete MigrationLogStore (MongoDB) instead of the IMigrationLogStore interface. In Postgres mode, CDI bypassed the DataStoreProducers routing and instantiated the MongoDB store directly. channelConnectorMigration.runIfNeeded() called readMigrationLog() outside any try-catch — the 30s MongoDB timeout exception killed autoDeployAgents(), preventing agentsReadiness from ever being set to true. Health check stayed DOWN indefinitely, causing 503 errors in PostgresInfrastructureIT and PostgresAgentUseCaseIT. Also: - Wrap each migration call in autoDeployAgents() with individual try-catch blocks so a single migration failure cannot block readiness or skip subsequent migrations - Disable MongoDB health check in Postgres test profile - Increase container startup timeouts to 180s - Add failsafe retry for flaky container startups in CI --- docs/changelog.md | 28 +++++++++++++++++++ .../migration/ChannelConnectorMigration.java | 4 +-- .../configs/migration/V6QuteMigration.java | 4 +-- .../configs/migration/V6RenameMigration.java | 4 +-- .../internal/AgentDeploymentManagement.java | 23 ++++++++++++--- .../ChannelConnectorMigrationTest.java | 4 +-- .../migration/V6QuteMigrationTest.java | 4 +-- .../migration/V6RenameMigrationTest.java | 2 +- 8 files changed, 58 insertions(+), 15 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 157b933fa..ec5f20b5c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -14,6 +14,34 @@ Each entry follows this format: - **Files** — Links to modified files +## Fix: Postgres Integration Tests — MigrationLogStore Injection (2026-04-26) + +**Repo:** EDDI (`feature/channel-integrations`) + +**What changed:** Fixed 503 Service Unavailable errors in `PostgresInfrastructureIT` and `PostgresAgentUseCaseIT` caused by MongoDB dependency in the Postgres test profile. + +### Root Cause + +`ChannelConnectorMigration`, `V6RenameMigration`, and `V6QuteMigration` all injected the concrete `MigrationLogStore` class (MongoDB implementation) instead of the `IMigrationLogStore` interface. When running with `eddi.datastore.type=postgres`, the `DataStoreProducers` correctly routes `IMigrationLogStore` to `PostgresMigrationLogStore`, but CDI injection of the **concrete class** bypasses the producer entirely. + +During startup, `channelConnectorMigration.runIfNeeded()` called `migrationLogStore.readMigrationLog()` which attempted to query MongoDB (not available in Postgres profile). This threw `MongoTimeoutException` after 30 seconds. Since this call was **outside** any try-catch block, the exception killed the entire `autoDeployAgents()` scheduled task, preventing `agentsReadiness.setAgentsReadiness(true)` from ever being called. The health check remained DOWN indefinitely. + +### Fix + +Changed all three migration classes to inject `IMigrationLogStore` (interface) instead of `MigrationLogStore` (concrete MongoDB class). The `DataStoreProducers` now correctly routes to the appropriate implementation based on `eddi.datastore.type`. + +**Files:** +- `ChannelConnectorMigration.java` — `MigrationLogStore` → `IMigrationLogStore` +- `V6RenameMigration.java` — `MigrationLogStore` → `IMigrationLogStore` +- `V6QuteMigration.java` — `MigrationLogStore` → `IMigrationLogStore` +- `ChannelConnectorMigrationTest.java` — updated mock type +- `V6QuteMigrationTest.java` — updated mock type +- `V6RenameMigrationTest.java` — updated mock type + +**Verification:** `mvnw compile` BUILD SUCCESS, `mvnw test` 94 migration tests pass (0 failures). + +--- + ## Channel Integration — External Review Round 4 (2026-04-19) **Repo:** EDDI (`feature/channel-integrations`) diff --git a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java index 502c9cc90..34de31b69 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java @@ -48,14 +48,14 @@ public class ChannelConnectorMigration { private final IAgentStore agentStore; private final IChannelIntegrationStore channelStore; private final IDocumentDescriptorStore descriptorStore; - private final MigrationLogStore migrationLogStore; + private final IMigrationLogStore migrationLogStore; @Inject public ChannelConnectorMigration(IDeploymentStore deploymentStore, IAgentStore agentStore, IChannelIntegrationStore channelStore, IDocumentDescriptorStore descriptorStore, - MigrationLogStore migrationLogStore) { + IMigrationLogStore migrationLogStore) { this.deploymentStore = deploymentStore; this.agentStore = agentStore; this.channelStore = channelStore; diff --git a/src/main/java/ai/labs/eddi/configs/migration/V6QuteMigration.java b/src/main/java/ai/labs/eddi/configs/migration/V6QuteMigration.java index e81a03b91..bbf27da25 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/V6QuteMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/V6QuteMigration.java @@ -34,12 +34,12 @@ public class V6QuteMigration { private static final String[] TEMPLATE_COLLECTIONS = {"apicalls", "outputs", "propertysetter", "llms"}; private final MongoDatabase database; - private final MigrationLogStore migrationLogStore; + private final IMigrationLogStore migrationLogStore; private final TemplateSyntaxMigrator migrator; private final boolean enabled; @Inject - public V6QuteMigration(MongoDatabase database, MigrationLogStore migrationLogStore, TemplateSyntaxMigrator migrator, + public V6QuteMigration(MongoDatabase database, IMigrationLogStore migrationLogStore, TemplateSyntaxMigrator migrator, @ConfigProperty(name = "eddi.migration.v6-qute.enabled", defaultValue = "false") boolean enabled) { this.database = database; this.migrationLogStore = migrationLogStore; diff --git a/src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java b/src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java index 6edf9619e..fec1c6c98 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java @@ -82,11 +82,11 @@ public class V6RenameMigration { "dictionaries", "parsers",}; private final MongoDatabase database; - private final MigrationLogStore migrationLogStore; + private final IMigrationLogStore migrationLogStore; private final boolean enabled; @Inject - public V6RenameMigration(MongoDatabase database, MigrationLogStore migrationLogStore, + public V6RenameMigration(MongoDatabase database, IMigrationLogStore migrationLogStore, @ConfigProperty(name = "eddi.migration.v6-rename.enabled", defaultValue = "false") boolean enabled) { this.database = database; this.migrationLogStore = migrationLogStore; diff --git a/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java b/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java index c78193154..db82f875c 100644 --- a/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java +++ b/src/main/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagement.java @@ -99,10 +99,25 @@ void onStart(@Observes StartupEvent ev) { public void autoDeployAgents() { LOGGER.info("Starting deployment of agents..."); - // V6 rename migration must run before document-level migrations - v6RenameMigration.runIfNeeded(); - v6QuteMigration.runIfNeeded(); - channelConnectorMigration.runIfNeeded(); + // V6 rename migration must run before document-level migrations. + // Each migration is independently guarded: a failure logs the error + // and lets the remaining migrations + agent deployment proceed. + // The failed migration will retry on next startup (flag not set). + try { + v6RenameMigration.runIfNeeded(); + } catch (Exception e) { + LOGGER.error("V6 rename migration failed — will retry on next startup", e); + } + try { + v6QuteMigration.runIfNeeded(); + } catch (Exception e) { + LOGGER.error("V6 Qute migration failed — will retry on next startup", e); + } + try { + channelConnectorMigration.runIfNeeded(); + } catch (Exception e) { + LOGGER.error("Channel connector migration failed — will retry on next startup", e); + } migrationManager.startMigrationIfFirstTimeRun(() -> { checkDeployments(); diff --git a/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java b/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java index 3d66e25f8..987719e36 100644 --- a/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java +++ b/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java @@ -37,7 +37,7 @@ class ChannelConnectorMigrationTest { private IAgentStore agentStore; private IChannelIntegrationStore channelStore; private IDocumentDescriptorStore descriptorStore; - private MigrationLogStore migrationLogStore; + private IMigrationLogStore migrationLogStore; private ChannelConnectorMigration migration; @BeforeEach @@ -46,7 +46,7 @@ void setUp() { agentStore = mock(IAgentStore.class); channelStore = mock(IChannelIntegrationStore.class); descriptorStore = mock(IDocumentDescriptorStore.class); - migrationLogStore = mock(MigrationLogStore.class); + migrationLogStore = mock(IMigrationLogStore.class); migration = new ChannelConnectorMigration( deploymentStore, agentStore, channelStore, descriptorStore, migrationLogStore); diff --git a/src/test/java/ai/labs/eddi/configs/migration/V6QuteMigrationTest.java b/src/test/java/ai/labs/eddi/configs/migration/V6QuteMigrationTest.java index 68b539957..20a90dfd4 100644 --- a/src/test/java/ai/labs/eddi/configs/migration/V6QuteMigrationTest.java +++ b/src/test/java/ai/labs/eddi/configs/migration/V6QuteMigrationTest.java @@ -16,13 +16,13 @@ class V6QuteMigrationTest { private MongoDatabase database; - private MigrationLogStore migrationLogStore; + private IMigrationLogStore migrationLogStore; private TemplateSyntaxMigrator migrator; @BeforeEach void setUp() { database = mock(MongoDatabase.class); - migrationLogStore = mock(MigrationLogStore.class); + migrationLogStore = mock(IMigrationLogStore.class); migrator = mock(TemplateSyntaxMigrator.class); } diff --git a/src/test/java/ai/labs/eddi/configs/migration/V6RenameMigrationTest.java b/src/test/java/ai/labs/eddi/configs/migration/V6RenameMigrationTest.java index bee7ec883..3b2c2fd36 100644 --- a/src/test/java/ai/labs/eddi/configs/migration/V6RenameMigrationTest.java +++ b/src/test/java/ai/labs/eddi/configs/migration/V6RenameMigrationTest.java @@ -22,7 +22,7 @@ class V6RenameMigrationTest { @Mock private MongoDatabase database; @Mock - private MigrationLogStore migrationLogStore; + private IMigrationLogStore migrationLogStore; private V6RenameMigration migration; From fed27e7fff2a887f5f892b1a2209101a624b66a1 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 26 Apr 2026 15:36:32 +0200 Subject: [PATCH 27/35] =?UTF-8?q?fix(channels):=20address=20review=20findi?= =?UTF-8?q?ngs=20=E2=80=94=20idempotency,=20intent=20keys,=20help,=20NPE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Migration idempotency (Critical): channelStore.create() generates a new UUID each time. On retry after partial failure, previously succeeded groups would be re-created as duplicates. Now pre-loads existing configs and skips any channelType:channelId that already has a config. 2. Intent key stability (Medium): conversation intent key used mutable names (integration.getName() + target.getName()). Renames broke IUserConversationStore lookups, silently starting new conversations. Now uses stable IDs: channelId + agentId. 3. Legacy help inconsistency (Medium): resolveTarget() returned null for 'help' on new-style channels but routed legacy channels to the default agent. Now applies same help/blank check before legacy fallback. 4. postHelp NPE (Low): name.equalsIgnoreCase(config.getDefaultTargetName()) crashed when defaultTargetName was null (corrupted config). Added null guard. 5. Comment + import cleanup: Updated misleading Javadoc about cached configs; added proper static import for RestUtilities. --- .../migration/ChannelConnectorMigration.java | 61 +++++++++++++++++-- .../channels/ChannelTargetRouter.java | 12 +++- .../integrations/slack/SlackEventHandler.java | 18 +++--- 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java index 34de31b69..eb9367831 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java @@ -13,11 +13,12 @@ import jakarta.inject.Inject; import org.jboss.logging.Logger; +import static ai.labs.eddi.configs.deployment.model.DeploymentInfo.DeploymentStatus.deployed; +import static ai.labs.eddi.utils.RestUtilities.extractResourceId; + import java.util.*; import java.util.Locale; -import static ai.labs.eddi.configs.deployment.model.DeploymentInfo.DeploymentStatus.deployed; - /** * One-shot startup migration: converts legacy {@link ChannelConnector} entries * embedded in {@link AgentConfiguration#getChannels()} into standalone @@ -76,10 +77,12 @@ public void runIfNeeded() { LOGGER.info("Starting channel connector migration..."); try { - int[] result = migrateConnectors(); // [created, failed] + int[] result = migrateConnectors(); // [created, failed, skipped] int created = result[0]; int failed = result[1]; - LOGGER.infof("Channel connector migration complete: %d configs created, %d failed", created, failed); + int skipped = result[2]; + LOGGER.infof("Channel connector migration complete: %d created, %d failed, %d skipped (already existed)", + created, failed, skipped); if (failed > 0) { LOGGER.errorf("Channel connector migration had %d failure(s) — " @@ -133,9 +136,21 @@ private int[] migrateConnectors() { throw new RuntimeException("Cannot read deployment infos", e); } + // Pre-load existing channel configs to avoid creating duplicates on retry. + // channelStore.create() generates a new UUID every time, so re-running + // after partial failure would accumulate duplicate configs without this check. + var existingKeys = loadExistingChannelKeys(); + int created = 0; + int skipped = 0; int failed = 0; for (var entry : channelGroups.entrySet()) { + // Skip if a config for this channelType:channelId already exists + if (existingKeys.contains(entry.getKey())) { + skipped++; + LOGGER.debugf(" Skipping %s — config already exists", entry.getKey()); + continue; + } var entries = entry.getValue(); // Sort for deterministic default target entries.sort(Comparator.comparing(ConnectorEntry::agentId)); @@ -206,13 +221,49 @@ private int[] migrateConnectors() { } } - return new int[]{created, failed}; + return new int[]{created, failed, skipped}; } private record ConnectorEntry(ChannelConnector connector, String agentId, String channelType, String agentName) { } + /** + * Load all existing channel integration configs and return their + * {@code channelType:channelId} keys. Used to detect duplicates on retry. + */ + private Set loadExistingChannelKeys() { + var keys = new HashSet(); + try { + var descriptors = descriptorStore.readDescriptors( + "ai.labs.channel", "", 0, 1000, false); + for (var descriptor : descriptors) { + try { + var resId = extractResourceId( + descriptor.getResource()); + if (resId == null || resId.getId() == null) + continue; + var config = channelStore.read(resId.getId(), resId.getVersion()); + if (config != null && config.getChannelType() != null + && config.getPlatformConfig() != null) { + String chId = config.getPlatformConfig().get("channelId"); + if (chId != null && !chId.isBlank()) { + keys.add(config.getChannelType().toLowerCase(Locale.ROOT) + + ":" + chId); + } + } + } catch (Exception e) { + LOGGER.debugf("Skipping descriptor during duplicate check: %s", + e.getMessage()); + } + } + } catch (Exception e) { + LOGGER.warn("Failed to load existing channel configs for duplicate check — " + + "migration will proceed but may create duplicates", e); + } + return keys; + } + /** * Look up the human-readable name for an agent via its document descriptor. * Returns {@code null} if the descriptor is not found or has no name. diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index d2235cf42..b73e89d48 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -57,9 +57,10 @@ public class ChannelTargetRouter { /** * channelType:channelId → deep-copied ChannelIntegrationConfiguration with - * resolved secrets. These instances are never returned to callers outside the - * router — the REST layer reads from the store directly and returns vault - * references. + * resolved secrets. These cached instances may be returned by router methods + * (e.g., {@link #getIntegration}) and must be treated as sensitive internal + * data that must not be logged or serialized. The REST layer reads from the + * store directly and returns vault references instead. */ private volatile Map integrationMap = Map.of(); @@ -124,6 +125,11 @@ public ResolvedTarget resolveTarget(String channelType, String platformChannelId if (CHANNEL_TYPE_SLACK.equals(normalizedType)) { LegacyTarget legacy = legacyMap.get(platformChannelId); if (legacy != null) { + // Apply same help/blank check as new-style path for consistency + String trimmed = messageText != null ? messageText.trim() : ""; + if (trimmed.isEmpty() || "help".equalsIgnoreCase(trimmed)) { + return null; + } return new ResolvedTarget(legacy.toChannelTarget(), messageText, null, legacy.botToken(), legacy.signingSecret()); } diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java index 79c066ad4..6dbcad5bb 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -234,14 +234,13 @@ private void handleAgentConversation(ResolvedTarget resolved, String channelId, String userId, String threadTs, String originalText) throws Exception { String agentId = resolved.target().getTargetId(); - String targetName = resolved.target().getName(); String threadKey = threadTs != null ? threadTs : "main"; - // Compose intent key for conversation tracking - String integrationId = resolved.integration() != null - ? resolved.integration().getName() - : "legacy"; - String intent = "channel:" + integrationId + ":" + targetName + ":" + threadKey; + // Compose a stable intent key for conversation tracking. + // Uses channelId + targetId (agentId/groupId) — NOT mutable display names + // like integration name or target name, which would break + // IUserConversationStore lookups on rename. + String intent = "channel:slack:" + channelId + ":" + agentId + ":" + threadKey; // Use strippedMessage (trigger keyword removed) or fall back to original text // (thread replies from resolveThreadTarget have strippedMessage=null) @@ -553,9 +552,10 @@ private void postHelp(String channelId, String threadTs) { for (ChannelTarget target : config.getTargets()) { String name = target.getName() != null ? target.getName() : "(unnamed)"; String type = target.getType() == ChannelTarget.TargetType.GROUP ? "group" : "agent"; - String isDefault = name.equalsIgnoreCase(config.getDefaultTargetName()) - ? " _(default)_" - : ""; + String isDefault = config.getDefaultTargetName() != null + && name.equalsIgnoreCase(config.getDefaultTargetName()) + ? " _(default)_" + : ""; sb.append("• *").append(name).append("*").append(isDefault); sb.append(" [").append(type).append("]\n"); if (target.getTriggers() != null && !target.getTriggers().isEmpty()) { From 4c209b2a249f998da3f0e33d883f8143d8859a48 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Thu, 14 May 2026 15:36:52 -0400 Subject: [PATCH 28/35] =?UTF-8?q?fix(channels):=20address=20code=20review?= =?UTF-8?q?=20=E2=80=94=20ThreadLocal=20removal,=20vault=20prefix,=20SPDX?= =?UTF-8?q?=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C1: Replace ThreadLocal with explicit botToken parameter passing through postMessage/postMessageChunked/postHelp (Loom safety) - C2: Document intent key format change in changelog - M1: Fix stale eddivault -> vault Javadoc in ChannelIntegrationConfiguration - M4: Fix trigger backtick placement in postHelp() - L2: Add SPDX copyright headers to all 12 new files --- docs/changelog.md | 25 ++++++ .../channels/IChannelIntegrationStore.java | 4 + .../ChannelIntegrationConfiguration.java | 6 +- .../configs/channels/model/ChannelTarget.java | 4 + .../configs/channels/model/ObserveConfig.java | 4 + .../mongo/ChannelIntegrationStore.java | 4 + .../migration/ChannelConnectorMigration.java | 4 + .../channels/ChannelTargetRouter.java | 4 + .../integrations/slack/SlackEventHandler.java | 88 +++++++++---------- .../channels/model/ChannelModelTest.java | 4 + ...ChannelIntegrationStoreValidationTest.java | 4 + .../ChannelConnectorMigrationTest.java | 4 + .../ChannelTargetRouterRefreshTest.java | 4 + .../channels/ChannelTargetRouterTest.java | 4 + 14 files changed, 118 insertions(+), 45 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index c34e6eddb..15026905f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,6 +15,31 @@ Each entry follows this format: +## Channel Integration — Pre-Merge Review Fixes (2026-05-14) + +**Repo:** EDDI (`feature/channel-integrations`) + +**What changed:** Addressed findings from thorough code review before merge. + +### Critical fixes +- **C1 — Removed `ThreadLocal`:** Virtual threads and `ThreadLocal` are a known Loom footgun — carrier thread reuse can leak stale values. Replaced with explicit `botToken` parameter passing through `postMessage()`, `postMessageChunked()`, and `postHelp()`. All callers now pass `botToken` (or `null` for router fallback) directly. +- **C2 — Intent key format change documented:** The conversation mapping intent key changed from `slack::` to `channel:slack:::`. This is intentional (adds agent specificity for multi-target channels) but means existing Slack conversation mappings from pre-6.1 will be orphaned — new conversations will be created. This is acceptable for a pre-GA feature with very few users. + +### Medium fixes +- **M1 — `eddivault` → `vault` Javadoc:** Updated stale `${eddivault:key-name}` reference in `ChannelIntegrationConfiguration` to `${vault:key-name}` (prefix was renamed on main in `1b884109`). +- **M4 — Trigger backtick formatting:** Fixed `postHelp()` to render triggers as `` `architect`: `` instead of `` `architect:` `` — the colon is part of the user syntax, not the keyword. + +### Low fixes +- **L2 — SPDX headers:** Added `Copyright EDDI contributors / Apache-2.0` headers to all 12 new files. + +### Merge conflicts resolved +- `docs/changelog.md` — both branches added entries; kept both sets. +- `SlackChannelRouter.java` / `SlackChannelRouterTest.java` — deleted on this branch, modified on main (CodeQL fixes). Resolved by keeping deletion (replaced by `ChannelTargetRouter`). + +**Files:** `SlackEventHandler.java`, `ChannelIntegrationConfiguration.java`, `docs/changelog.md`, 12 new files (SPDX headers) + +--- + ## Fix: Postgres Integration Tests — MigrationLogStore Injection (2026-04-26) **Repo:** EDDI (`feature/channel-integrations`) diff --git a/src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java index 6a15a443a..f95e3c53d 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.channels; import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; diff --git a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java index 409c766a2..3fc5dc49e 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.channels.model; import com.fasterxml.jackson.annotation.JsonInclude; @@ -69,7 +73,7 @@ public void setChannelType(String channelType) { *

  • discord: {@code guildId}, {@code channelId}, {@code botToken}, * {@code publicKey}
  • * - * Secret values should use vault references: {@code ${eddivault:key-name}}. + * Secret values should use vault references: {@code ${vault:key-name}}. */ public Map getPlatformConfig() { return new HashMap<>(platformConfig); diff --git a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java index 0ebd5f29b..e47f65d87 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.channels.model; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/ai/labs/eddi/configs/channels/model/ObserveConfig.java b/src/main/java/ai/labs/eddi/configs/channels/model/ObserveConfig.java index c291f2c8c..d1d3c5bb8 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/model/ObserveConfig.java +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ObserveConfig.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.channels.model; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/ai/labs/eddi/configs/channels/mongo/ChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/mongo/ChannelIntegrationStore.java index f90d40447..71cb7478c 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/mongo/ChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/mongo/ChannelIntegrationStore.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.channels.mongo; import ai.labs.eddi.configs.channels.IChannelIntegrationStore; diff --git a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java index eb9367831..86f8fc5d1 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.migration; import ai.labs.eddi.configs.agents.IAgentStore; diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index b73e89d48..97e540528 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.integrations.channels; import ai.labs.eddi.configs.agents.IRestAgentStore; diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java index 835a9489e..67251c34e 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -155,7 +155,8 @@ public void handleEventAsync(String eventId, Map event) { if (channelId != null) { try { postMessage(channelId, threadTs, - "⚠️ Sorry, I encountered an error processing your message. Please try again."); + "⚠️ Sorry, I encountered an error processing your message. Please try again.", + null); } catch (Exception ignored) { // Can't post error — nothing more we can do } @@ -186,7 +187,7 @@ private void handleEvent(Map event) throws Exception { String threadTs = getThreadTs(event); if (text.isBlank()) { - postHelp(channelId, threadTs); + postHelp(channelId, threadTs, null); return; } @@ -210,7 +211,7 @@ && tryHandleAgentFollowUp(parentTs, channelId, userId, text, threadTs)) { } if (resolved == null) { - postHelp(channelId, threadTs); + postHelp(channelId, threadTs, null); return; } @@ -219,15 +220,11 @@ && tryHandleAgentFollowUp(parentTs, channelId, userId, text, threadTs)) { channelTargetRouter.lockThreadTarget("slack", channelId, threadTs, resolved.target()); } - // Store resolved target for credential resolution in postMessage - currentResolvedTarget.set(resolved); - try { - switch (resolved.target().getType()) { - case AGENT -> handleAgentConversation(resolved, channelId, userId, threadTs, text); - case GROUP -> handleGroupDiscussion(resolved, channelId, userId, threadTs, text); - } - } finally { - currentResolvedTarget.remove(); + // Resolve bot token once — passed explicitly to all post methods + String botToken = resolved.botToken(); + switch (resolved.target().getType()) { + case AGENT -> handleAgentConversation(resolved, channelId, userId, threadTs, text, botToken); + case GROUP -> handleGroupDiscussion(resolved, channelId, userId, threadTs, text, botToken); } } @@ -235,7 +232,8 @@ && tryHandleAgentFollowUp(parentTs, channelId, userId, text, threadTs)) { * Handle a standard 1:1 agent conversation routed via ChannelTargetRouter. */ private void handleAgentConversation(ResolvedTarget resolved, String channelId, - String userId, String threadTs, String originalText) + String userId, String threadTs, String originalText, + String botToken) throws Exception { String agentId = resolved.target().getTargetId(); String threadKey = threadTs != null ? threadTs : "main"; @@ -252,7 +250,7 @@ private void handleAgentConversation(ResolvedTarget resolved, String channelId, String conversationId = getOrCreateConversation(agentId, userId, intent); String response = sendAndWait(conversationId, message); - postMessageChunked(channelId, threadTs, response); + postMessageChunked(channelId, threadTs, response, botToken); } // ─── Group Discussion ─── @@ -261,9 +259,9 @@ private void handleAgentConversation(ResolvedTarget resolved, String channelId, * Handle a group discussion trigger routed via ChannelTargetRouter. */ private void handleGroupDiscussion(ResolvedTarget resolved, String channelId, - String userId, String threadTs, String originalText) { + String userId, String threadTs, String originalText, + String botToken) { String groupId = resolved.target().getTargetId(); - String botToken = resolved.botToken(); if (botToken == null || botToken.isEmpty()) { LOGGER.errorf("No bot token configured for Slack channel %s — cannot run group discussion.", channelId); @@ -291,7 +289,8 @@ private void handleGroupDiscussion(ResolvedTarget resolved, String channelId, } catch (Exception e) { LOGGER.errorf(e, "Failed to start group discussion: %s", e.getMessage()); postMessage(channelId, threadTs, - "⚠️ Failed to start group discussion. Please try again."); + "⚠️ Sorry, I couldn't start the group discussion. Please try again.", + botToken); } } @@ -349,7 +348,7 @@ private boolean tryHandleAgentFollowUp(String parentTs, String channelId, String intent = "channel:followup:" + channelId + ":" + parentTs; String conversationId = getOrCreateConversation(agentId, userId, intent); String response = sendAndWait(conversationId, enrichedInput); - postMessageChunked(channelId, threadTs, response); + postMessageChunked(channelId, threadTs, response, null); return true; } @@ -459,10 +458,14 @@ private String extractResponseText(SimpleConversationMemorySnapshot snapshot) { /** * Post a message to Slack, chunking if it exceeds Slack's 4000-char limit. + * + * @param botToken + * explicit bot token (if {@code null}, falls back to router lookup) */ - private void postMessageChunked(String channelId, String threadTs, String text) { + private void postMessageChunked(String channelId, String threadTs, String text, + String botToken) { if (text.length() <= MAX_SLACK_MESSAGE_LENGTH) { - postMessage(channelId, threadTs, text); + postMessage(channelId, threadTs, text, botToken); return; } @@ -481,38 +484,32 @@ private void postMessageChunked(String channelId, String threadTs, String text) if (end <= offset) { end = Math.min(offset + MAX_SLACK_MESSAGE_LENGTH, text.length()); } - postMessage(channelId, threadTs, text.substring(offset, end)); + postMessage(channelId, threadTs, text.substring(offset, end), botToken); offset = end; } } /** - * Thread-local storage for the current resolved target. Used by postMessage to - * resolve the bot token without passing it through every method. - */ - private final ThreadLocal currentResolvedTarget = new ThreadLocal<>(); - - /** - * Post a single message to Slack via the Web API, using the bot token from the - * current resolved target or from the channel target router. + * Post a single message to Slack via the Web API. + * + * @param botToken + * explicit bot token; if {@code null}, falls back to + * {@link ChannelTargetRouter#getBotToken} */ - private void postMessage(String channelId, String threadTs, String text) { - // Resolve bot token: prefer current resolved target, fallback to router - String botToken = null; - ResolvedTarget resolved = currentResolvedTarget.get(); - if (resolved != null) { - botToken = resolved.botToken(); - } - if (botToken == null || botToken.isEmpty()) { - botToken = channelTargetRouter.getBotToken("slack", channelId); + private void postMessage(String channelId, String threadTs, String text, + String botToken) { + // Resolve bot token: prefer explicit parameter, fallback to router + String resolvedToken = botToken; + if (resolvedToken == null || resolvedToken.isEmpty()) { + resolvedToken = channelTargetRouter.getBotToken("slack", channelId); } - if (botToken == null || botToken.isEmpty()) { + if (resolvedToken == null || resolvedToken.isEmpty()) { LOGGER.warnf("No bot token configured for Slack channel %s — cannot post message", channelId); return; } - String auth = "Bearer " + botToken; + String auth = "Bearer " + resolvedToken; for (int attempt = 1; attempt <= SLACK_API_MAX_RETRIES; attempt++) { try { @@ -540,12 +537,15 @@ private void postMessage(String channelId, String threadTs, String text) { /** * Post a help message listing available targets for this channel. + * + * @param botToken + * explicit bot token (if {@code null}, falls back to router lookup) */ - private void postHelp(String channelId, String threadTs) { + private void postHelp(String channelId, String threadTs, String botToken) { var integration = channelTargetRouter.getIntegration("slack", channelId); if (integration.isEmpty()) { postMessage(channelId, threadTs, - "👋 Hi! Send me a message and I'll respond."); + "👋 Hi! Send me a message and I'll respond.", botToken); return; } @@ -565,14 +565,14 @@ private void postHelp(String channelId, String threadTs) { if (target.getTriggers() != null && !target.getTriggers().isEmpty()) { sb.append(" Triggers: "); sb.append(String.join(", ", target.getTriggers().stream() - .map(t -> "`" + t + ":`") + .map(t -> "`" + t + "`" + ":") .toList())); sb.append("\n"); } } sb.append("\n_Type a message to talk to the default target, or use a trigger keyword._"); - postMessage(channelId, threadTs, sb.toString()); + postMessage(channelId, threadTs, sb.toString(), botToken); } /** diff --git a/src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java b/src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java index 83b85c3ed..eb69de89c 100644 --- a/src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java +++ b/src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.channels.model; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java index 24c72cb3f..c177c4180 100644 --- a/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java +++ b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.channels.rest; import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; diff --git a/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java b/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java index 987719e36..6770f28d9 100644 --- a/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java +++ b/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.migration; import ai.labs.eddi.configs.agents.IAgentStore; diff --git a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java index c826ec2f7..f075aaf49 100644 --- a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.integrations.channels; import ai.labs.eddi.configs.agents.IRestAgentStore; diff --git a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java index a98c2daa7..e52e52731 100644 --- a/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.integrations.channels; import ai.labs.eddi.configs.channels.model.ChannelIntegrationConfiguration; From dc7b570436ac570427e3b4af66187508490104eb Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Thu, 14 May 2026 16:04:36 -0400 Subject: [PATCH 29/35] =?UTF-8?q?fix(channels):=20second-pass=20review=20?= =?UTF-8?q?=E2=80=94=20log=20sanitization,=20defensive=20copies,=20SPDX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M5: Fix stale eddivault -> vault in ChannelTargetRouter deepCopyConfig Javadoc - M6: Add SPDX headers to IRestChannelIntegrationStore, RestChannelIntegrationStore - L3: Apply LogSanitizer.sanitize() to all Slack-sourced log parameters (CodeQL) - L4: Return defensive copy from ChannelTarget.getTriggers() - L5: Add null guard to postMessageChunked() - L6: Add ObserveConfig bounds validation (cooldown, maxDaily, maxCost >= 0) --- .../IRestChannelIntegrationStore.java | 4 ++++ .../configs/channels/model/ChannelTarget.java | 2 +- .../rest/RestChannelIntegrationStore.java | 20 +++++++++++++++++ .../channels/ChannelTargetRouter.java | 4 ++-- .../integrations/slack/SlackEventHandler.java | 22 +++++++++++-------- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/main/java/ai/labs/eddi/configs/channels/IRestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/IRestChannelIntegrationStore.java index 9798a6f66..4d8471f5b 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/IRestChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/IRestChannelIntegrationStore.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.channels; import ai.labs.eddi.configs.IRestVersionInfo; diff --git a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java index e47f65d87..dc51894a0 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java @@ -57,7 +57,7 @@ public void setName(String name) { * followed by a colon to address this target: {@code architect: question}. */ public List getTriggers() { - return triggers; + return triggers != null ? new ArrayList<>(triggers) : null; } public void setTriggers(List triggers) { diff --git a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java index 4a73c4c87..a9c5fca9e 100644 --- a/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java +++ b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java @@ -1,3 +1,7 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ package ai.labs.eddi.configs.channels.rest; import ai.labs.eddi.configs.channels.IChannelIntegrationStore; @@ -194,6 +198,22 @@ void validateConfiguration(ChannelIntegrationConfiguration config) { + "': observeMode is not yet implemented. " + "Set observeMode to false or omit it."); } + // Future-proofing: validate ObserveConfig bounds even while rejected + if (target.getObserveConfig() != null) { + var oc = target.getObserveConfig(); + if (oc.getCooldownSeconds() < 0) { + throw new BadRequestException( + "Target '" + target.getName() + "': cooldownSeconds must be >= 0."); + } + if (oc.getMaxDailyResponses() < 0) { + throw new BadRequestException( + "Target '" + target.getName() + "': maxDailyResponses must be >= 0."); + } + if (oc.getMaxCostPerDay() < 0) { + throw new BadRequestException( + "Target '" + target.getName() + "': maxCostPerDay must be >= 0."); + } + } if (target.getTriggers() != null) { for (String trigger : target.getTriggers()) { if (trigger == null || trigger.isBlank()) { diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index 97e540528..fae6d0545 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -450,8 +450,8 @@ private void refreshInternal() { /** * Deep-copy a config so that secret resolution does not mutate the store's - * cached instance (which must retain {@code ${eddivault:...}} references for - * the REST API). + * cached instance (which must retain {@code ${vault:...}} references for the + * REST API). *

    * Invariant: {@code ChannelTarget} instances are shared by reference * between the copy and the original. The router must never mutate target diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java index 67251c34e..327e0dedc 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -23,6 +23,8 @@ import jakarta.inject.Inject; import org.jboss.logging.Logger; +import static ai.labs.eddi.utils.LogSanitizer.sanitize; + import java.time.Duration; import java.util.*; import java.util.concurrent.*; @@ -138,7 +140,7 @@ public void handleEventAsync(String eventId, Map event) { // De-duplicate: Slack retries events up to 3 times if (eventDedup.get(eventId) != null) { - LOGGER.debugf("Duplicate Slack event %s — skipping", eventId); + LOGGER.debugf("Duplicate Slack event %s — skipping", sanitize(eventId)); return; } eventDedup.put(eventId, Boolean.TRUE); @@ -147,7 +149,7 @@ public void handleEventAsync(String eventId, Map event) { try { handleEvent(event); } catch (Exception e) { - LOGGER.errorf(e, "Error handling Slack event %s", eventId); + LOGGER.errorf(e, "Error handling Slack event %s", sanitize(eventId)); // Best-effort error response to user (never leak internal details) String channelId = (String) event.get("channel"); @@ -168,7 +170,7 @@ public void handleEventAsync(String eventId, Map event) { private void handleEvent(Map event) throws Exception { // Filter bot's own messages (prevent infinite loop) if (event.containsKey("bot_id") || "bot_message".equals(event.get("subtype"))) { - LOGGER.debugf("Ignoring bot message in channel %s", event.get("channel")); + LOGGER.debugf("Ignoring bot message in channel %s", sanitize(String.valueOf(event.get("channel")))); return; } @@ -264,7 +266,7 @@ private void handleGroupDiscussion(ResolvedTarget resolved, String channelId, String groupId = resolved.target().getTargetId(); if (botToken == null || botToken.isEmpty()) { - LOGGER.errorf("No bot token configured for Slack channel %s — cannot run group discussion.", channelId); + LOGGER.errorf("No bot token configured for Slack channel %s — cannot run group discussion.", sanitize(channelId)); return; } @@ -276,7 +278,7 @@ private void handleGroupDiscussion(ResolvedTarget resolved, String channelId, String question = resolved.strippedMessage() != null ? resolved.strippedMessage() : originalText; try { LOGGER.infof("Starting group discussion in channel %s, group %s, question: %s", - channelId, groupId, question.substring(0, Math.min(80, question.length()))); + sanitize(channelId), sanitize(groupId), sanitize(question.substring(0, Math.min(80, question.length())))); groupConversationService.startAndDiscussAsync(groupId, question, userId, listener); @@ -339,7 +341,7 @@ private boolean tryHandleAgentFollowUp(String parentTs, String channelId, } LOGGER.infof("Follow-up in agent %s thread from user %s: %s", - ctx.displayName(), userId, text.substring(0, Math.min(60, text.length()))); + sanitize(ctx.displayName()), sanitize(userId), sanitize(text.substring(0, Math.min(60, text.length())))); // Build context-enriched input String enrichedInput = buildFollowUpInput(ctx, text); @@ -402,7 +404,7 @@ private String getOrCreateConversation(String agentId, String slackUserId, try { userConversationStore.createUserConversation(mapping); } catch (IResourceStore.ResourceAlreadyExistsException e) { - LOGGER.debugf("Race condition: conversation mapping already exists for %s/%s", intent, slackUserId); + LOGGER.debugf("Race condition: conversation mapping already exists for %s/%s", sanitize(intent), sanitize(slackUserId)); } return result.conversationId(); @@ -464,6 +466,8 @@ private String extractResponseText(SimpleConversationMemorySnapshot snapshot) { */ private void postMessageChunked(String channelId, String threadTs, String text, String botToken) { + if (text == null || text.isEmpty()) + return; if (text.length() <= MAX_SLACK_MESSAGE_LENGTH) { postMessage(channelId, threadTs, text, botToken); return; @@ -505,7 +509,7 @@ private void postMessage(String channelId, String threadTs, String text, } if (resolvedToken == null || resolvedToken.isEmpty()) { - LOGGER.warnf("No bot token configured for Slack channel %s — cannot post message", channelId); + LOGGER.warnf("No bot token configured for Slack channel %s — cannot post message", sanitize(channelId)); return; } @@ -528,7 +532,7 @@ private void postMessage(String channelId, String threadTs, String text, } } else { LOGGER.errorf("SLACK_DELIVERY_FAILED | channel=%s | threadTs=%s | textLength=%d | attempts=%d | error=%s", - channelId, threadTs, text != null ? text.length() : 0, + sanitize(channelId), sanitize(threadTs), text != null ? text.length() : 0, SLACK_API_MAX_RETRIES, e.getMessage()); } } From 65ec2723e720053bbe6907769060b92f07af2fac Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Thu, 14 May 2026 16:05:12 -0400 Subject: [PATCH 30/35] docs(changelog): add second-pass review fixes entry --- docs/changelog.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 15026905f..0233375ce 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,6 +15,23 @@ Each entry follows this format: +## Channel Integration — Second-Pass Review Fixes (2026-05-14) + +**Repo:** EDDI (`feature/channel-integrations`) + +**What changed:** Second critical review pass, 6 additional findings fixed. + +- **M5**: Fixed stale `${eddivault:...}` → `${vault:...}` in `ChannelTargetRouter.deepCopyConfig()` Javadoc +- **M6**: Added SPDX headers to `IRestChannelIntegrationStore`, `RestChannelIntegrationStore` (missed in first pass) +- **L3**: Applied `LogSanitizer.sanitize()` to all Slack-sourced log parameters in `SlackEventHandler` (CodeQL compliance) +- **L4**: `ChannelTarget.getTriggers()` now returns a defensive copy (consistent with `getTargets()`/`getPlatformConfig()`) +- **L5**: Added null guard to `postMessageChunked()` to prevent NPE on null text +- **L6**: Added `ObserveConfig` bounds validation (`cooldownSeconds`, `maxDailyResponses`, `maxCostPerDay` ≥ 0) + +**Files:** `ChannelTargetRouter.java`, `IRestChannelIntegrationStore.java`, `RestChannelIntegrationStore.java`, `ChannelTarget.java`, `SlackEventHandler.java` + +--- + ## Channel Integration — Pre-Merge Review Fixes (2026-05-14) **Repo:** EDDI (`feature/channel-integrations`) From f6ded96b47646bc180dde0fa24db46453c0444c1 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 17 May 2026 11:22:07 -0400 Subject: [PATCH 31/35] =?UTF-8?q?refactor(slack):=20remove=20eddi.slack.en?= =?UTF-8?q?abled=20server=20toggle=20=E2=80=94=20config-driven=20activatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhook endpoint is now always active. Signing secret verification serves as the natural gate: if no ChannelIntegrationConfiguration entries exist for Slack, there are no signing secrets, and the webhook returns 403. This aligns with EDDI's config-driven philosophy — agent behavior is controlled by JSON configs, not server-level env vars. Removed: - SlackIntegrationConfig.java (ConfigMapping interface) - eddi.slack.enabled property from application.properties - config.enabled() checks in RestSlackWebhook and SlackEventHandler - 'Disabled' test case in RestSlackWebhookTest - Server-Level config section from slack-integration.md --- docs/slack-integration.md | 23 ++------------ .../integrations/slack/SlackEventHandler.java | 9 +----- .../slack/SlackIntegrationConfig.java | 29 ------------------ .../slack/rest/RestSlackWebhook.java | 12 +------- src/main/resources/application.properties | 22 -------------- .../slack/rest/RestSlackWebhookTest.java | 30 +------------------ 6 files changed, 6 insertions(+), 119 deletions(-) delete mode 100644 src/main/java/ai/labs/eddi/integrations/slack/SlackIntegrationConfig.java diff --git a/docs/slack-integration.md b/docs/slack-integration.md index 5ba26ff06..263c67509 100644 --- a/docs/slack-integration.md +++ b/docs/slack-integration.md @@ -28,17 +28,7 @@ Add these **Bot Token Scopes**: 2. Copy the **Bot User OAuth Token** (starts with `xoxb-`) 3. Copy the **Signing Secret** from **Basic Information** -### 4. Enable Slack in EDDI - -Set the master toggle (environment variable or `application.properties`): - -```properties -eddi.slack.enabled=true -``` - -This is the only server-level setting. All credentials are configured per-agent. - -### 5. Store Credentials in Vault +### 4. Store Credentials in Vault Store your Slack credentials in EDDI's Secrets Vault: @@ -53,7 +43,7 @@ curl -X POST http://localhost:7070/secretstore/keys \ -d '{"keyName":"slack-signing-secret","secretValue":"your-signing-secret"}' ``` -### 6. Configure Channel Mapping on Your Agent +### 5. Configure Channel Mapping on Your Agent Add a `ChannelConnector` to your agent configuration: @@ -77,7 +67,7 @@ The `channelId` is the Slack channel ID (find it in Slack by right-clicking a ch > **Multi-workspace**: Each agent can use different bot tokens and signing secrets, allowing a single EDDI instance to serve multiple Slack workspaces. -### 7. Enable Event Subscriptions in Slack +### 6. Enable Event Subscriptions in Slack > ⚠️ **This step must come last.** When you set the Request URL, Slack immediately sends a signed `url_verification` challenge. EDDI verifies this using the signing secrets from step 6. If no agent is configured yet, verification fails and Slack rejects the URL. @@ -262,12 +252,6 @@ When running EDDI as a multi-instance cluster behind a load balancer: ## Configuration Reference -### Server-Level - -| Property | Default | Description | -|----------|---------|-------------| -| `eddi.slack.enabled` | `false` | Master toggle — infrastructure-level kill switch | - ### Per-Agent ChannelConnector Config Configure on each agent's `channels[]` array: @@ -340,7 +324,6 @@ During a multi-agent group discussion, individual Slack post failures do **not** | Check | Fix | |-------|-----| -| `eddi.slack.enabled=true` ? | Set in `application.properties` or env var | | Bot token configured? | Check agent's ChannelConnector config — `botToken` should reference a vault key | | Bot in channel? | Invite the bot to the channel in Slack | | Event subscription active? | Check **Event Subscriptions** in Slack app settings | diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java index 327e0dedc..eea440cda 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -70,7 +70,6 @@ public class SlackEventHandler { /** Maximum Slack message length (safe limit under 4000). */ private static final int MAX_SLACK_MESSAGE_LENGTH = 3900; - private final SlackIntegrationConfig config; private final ChannelTargetRouter channelTargetRouter; private final SlackWebApiClient slackApi; private final IConversationService conversationService; @@ -92,14 +91,12 @@ public class SlackEventHandler { private final ICache activeGroupListeners; @Inject - public SlackEventHandler(SlackIntegrationConfig config, - ChannelTargetRouter channelTargetRouter, + public SlackEventHandler(ChannelTargetRouter channelTargetRouter, SlackWebApiClient slackApi, IConversationService conversationService, IGroupConversationService groupConversationService, IUserConversationStore userConversationStore, ICacheFactory cacheFactory) { - this.config = config; this.channelTargetRouter = channelTargetRouter; this.slackApi = slackApi; this.conversationService = conversationService; @@ -134,10 +131,6 @@ void shutdown() { * the parsed event JSON as a Map */ public void handleEventAsync(String eventId, Map event) { - if (!config.enabled()) { - return; - } - // De-duplicate: Slack retries events up to 3 times if (eventDedup.get(eventId) != null) { LOGGER.debugf("Duplicate Slack event %s — skipping", sanitize(eventId)); diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackIntegrationConfig.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackIntegrationConfig.java deleted file mode 100644 index 1dcb5e085..000000000 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackIntegrationConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright EDDI contributors - * SPDX-License-Identifier: Apache-2.0 - */ -package ai.labs.eddi.integrations.slack; - -import io.smallrye.config.ConfigMapping; -import io.smallrye.config.WithDefault; - -/** - * Slack integration configuration. Only the master toggle lives at server - * level. All credentials (bot token, signing secret) and routing (channel → - * agent) are configured per-agent via {@code ChannelConnector} entries. - *

    - * This keeps a single infrastructure-level kill switch while allowing each - * agent to connect to its own Slack workspace independently. - * - * @since 6.0.0 - */ -@ConfigMapping(prefix = "eddi.slack") -public interface SlackIntegrationConfig { - - /** - * Master toggle for the Slack integration. When false, the webhook endpoint - * returns 404 and no Slack-related scanning happens. - */ - @WithDefault("false") - boolean enabled(); -} diff --git a/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java b/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java index 63dc0116b..b384a7759 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java @@ -6,7 +6,6 @@ import ai.labs.eddi.integrations.channels.ChannelTargetRouter; import ai.labs.eddi.integrations.slack.SlackEventHandler; -import ai.labs.eddi.integrations.slack.SlackIntegrationConfig; import ai.labs.eddi.integrations.slack.SlackSignatureVerifier; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -50,19 +49,16 @@ public class RestSlackWebhook { private static final TypeReference> MAP_TYPE = new TypeReference<>() { }; - private final SlackIntegrationConfig config; private final ChannelTargetRouter channelTargetRouter; private final SlackSignatureVerifier signatureVerifier; private final SlackEventHandler eventHandler; private final ObjectMapper objectMapper; @Inject - public RestSlackWebhook(SlackIntegrationConfig config, - ChannelTargetRouter channelTargetRouter, + public RestSlackWebhook(ChannelTargetRouter channelTargetRouter, SlackSignatureVerifier signatureVerifier, SlackEventHandler eventHandler, ObjectMapper objectMapper) { - this.config = config; this.channelTargetRouter = channelTargetRouter; this.signatureVerifier = signatureVerifier; this.eventHandler = eventHandler; @@ -87,12 +83,6 @@ public Response handleEvents(String rawBody, @HeaderParam("X-Slack-Signature") String signature, @HeaderParam("X-Slack-Request-Timestamp") String timestamp) { - if (!config.enabled()) { - return Response.status(Response.Status.NOT_FOUND) - .entity("{\"error\":\"Slack integration is not enabled\"}") - .build(); - } - // Step 1: Verify signature against all known signing secrets Set signingSecrets = channelTargetRouter.getSigningSecrets("slack"); if (!signatureVerifier.verify(timestamp, rawBody, signature, signingSecrets)) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d783e34b6..a6d6a729b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -247,28 +247,6 @@ quarkus.container-image.additional-tags=6.0.2 # Streamable HTTP transport at /mcp endpoint quarkus.mcp-server.http.root-path=/mcp -# ╔══════════════════════════════════════════════════════════════════════════════╗ -# ║ INTEGRATIONS: Slack ║ -# ║ ║ -# ║ When enabled, EDDI exposes a webhook endpoint at ║ -# ║ POST /integrations/slack/events ║ -# ║ that receives Slack Events API callbacks (app_mention, message). ║ -# ║ ║ -# ║ All credentials and channel routing are configured PER-AGENT via ║ -# ║ ChannelConnector entries in the agent configuration: ║ -# ║ ║ -# ║ { "channels": [{ "type": "slack", "config": { ║ -# ║ "channelId": "C0123...", ║ -# ║ "botToken": "${eddivault:slack-bot-token}", ║ -# ║ "signingSecret": "${eddivault:slack-signing-secret}", ║ -# ║ "groupId": "optional-group-id" ║ -# ║ }}]} ║ -# ║ ║ -# ║ This allows each agent to connect to its own Slack workspace. ║ -# ║ The master toggle below is an infrastructure-level kill switch. ║ -# ╚══════════════════════════════════════════════════════════════════════════════╝ -eddi.slack.enabled=false - # ╔══════════════════════════════════════════════════════════════════════════════╗ # ║ OBSERVABILITY: OpenTelemetry Distributed Tracing ║ # ║ ║ diff --git a/src/test/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhookTest.java b/src/test/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhookTest.java index 8a2fab23d..4bea11d04 100644 --- a/src/test/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhookTest.java +++ b/src/test/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhookTest.java @@ -6,7 +6,6 @@ import ai.labs.eddi.integrations.channels.ChannelTargetRouter; import ai.labs.eddi.integrations.slack.SlackEventHandler; -import ai.labs.eddi.integrations.slack.SlackIntegrationConfig; import ai.labs.eddi.integrations.slack.SlackSignatureVerifier; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.ws.rs.core.Response; @@ -27,7 +26,6 @@ */ class RestSlackWebhookTest { - private SlackIntegrationConfig config; private ChannelTargetRouter channelTargetRouter; private SlackSignatureVerifier signatureVerifier; private SlackEventHandler eventHandler; @@ -36,34 +34,15 @@ class RestSlackWebhookTest { @BeforeEach void setUp() { - config = mock(SlackIntegrationConfig.class); channelTargetRouter = mock(ChannelTargetRouter.class); signatureVerifier = mock(SlackSignatureVerifier.class); eventHandler = mock(SlackEventHandler.class); objectMapper = new ObjectMapper(); - webhook = new RestSlackWebhook(config, channelTargetRouter, signatureVerifier, + webhook = new RestSlackWebhook(channelTargetRouter, signatureVerifier, eventHandler, objectMapper); } - // ─── Disabled ────────────────────────────────────────────────────────────── - - @Nested - @DisplayName("Disabled integration") - class Disabled { - - @Test - @DisplayName("returns 404 when slack integration is disabled") - void returns404WhenDisabled() { - when(config.enabled()).thenReturn(false); - - Response response = webhook.handleEvents("{}", "sig", "ts"); - - assertEquals(404, response.getStatus()); - verifyNoInteractions(signatureVerifier, eventHandler); - } - } - // ─── Signature verification ─────────────────────────────────────────────── @Nested @@ -73,7 +52,6 @@ class SignatureVerification { @Test @DisplayName("returns 403 when signature verification fails") void returns403OnBadSignature() { - when(config.enabled()).thenReturn(true); when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); when(signatureVerifier.verify(eq("ts"), eq("{}"), eq("bad-sig"), any())) .thenReturn(false); @@ -94,7 +72,6 @@ class UrlVerification { @Test @DisplayName("echoes challenge for url_verification type") void echoesChallenge() { - when(config.enabled()).thenReturn(true); when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); @@ -110,7 +87,6 @@ void echoesChallenge() { @Test @DisplayName("handles null challenge gracefully") void handleNullChallenge() { - when(config.enabled()).thenReturn(true); when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); @@ -131,7 +107,6 @@ class EventCallback { @Test @DisplayName("delegates event_callback to eventHandler") void delegatesEventCallback() { - when(config.enabled()).thenReturn(true); when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); @@ -146,7 +121,6 @@ void delegatesEventCallback() { @Test @DisplayName("returns 200 even when event is null") void nullEvent() { - when(config.enabled()).thenReturn(true); when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); @@ -161,7 +135,6 @@ void nullEvent() { @Test @DisplayName("unknown type returns 200 (Slack expects it)") void unknownType() { - when(config.enabled()).thenReturn(true); when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); @@ -182,7 +155,6 @@ class MalformedPayload { @Test @DisplayName("returns 400 for invalid JSON") void invalidJson() { - when(config.enabled()).thenReturn(true); when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); From 50fe538e1803539bf844a3a6222913f736a1d0e3 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 17 May 2026 11:28:05 -0400 Subject: [PATCH 32/35] fix(slack): sanitize attacker-controlled headers in log statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RestSlackWebhook: timestamp from X-Slack-Request-Timestamp logged raw SlackSignatureVerifier: timestamp logged raw on NumberFormatException Both are attacker-controlled HTTP headers — CWE-117 log injection. Also fix stale doc reference to removed master toggle in lessons table. --- docs/slack-integration.md | 2 +- .../labs/eddi/integrations/slack/SlackSignatureVerifier.java | 4 +++- .../labs/eddi/integrations/slack/rest/RestSlackWebhook.java | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/slack-integration.md b/docs/slack-integration.md index 263c67509..8ae4dab17 100644 --- a/docs/slack-integration.md +++ b/docs/slack-integration.md @@ -448,4 +448,4 @@ Add your platform type (e.g., `teams`, `discord`) to the `ChannelConnector.type` | **Fire-and-forget in listeners** | A failed Slack post should not crash the entire multi-agent discussion. Wrap in try/catch. | | **Structured exhaustion logs** | After retry exhaustion, log enough context (channel, thread, text length, error) for operator recovery. | | **Never leak internal IDs to users** | Error messages should be generic. Log the details server-side. | -| **All credentials in ChannelConnector** | Per-agent credentials via vault references. No server-level secrets except the master toggle. | +| **All credentials in config** | Per-channel credentials via vault references. No server-level secrets. | diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackSignatureVerifier.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackSignatureVerifier.java index 95e0b66ec..1673489dc 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackSignatureVerifier.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackSignatureVerifier.java @@ -7,6 +7,8 @@ import jakarta.enterprise.context.ApplicationScoped; import org.jboss.logging.Logger; +import static ai.labs.eddi.utils.LogSanitizer.sanitize; + import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; @@ -81,7 +83,7 @@ public boolean verify(String timestamp, String rawBody, String signature, return false; } } catch (NumberFormatException e) { - LOGGER.warnf("Invalid Slack timestamp: %s", timestamp); + LOGGER.warnf("Invalid Slack timestamp: %s", sanitize(timestamp)); return false; } diff --git a/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java b/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java index b384a7759..f0e67ab20 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java @@ -86,7 +86,7 @@ public Response handleEvents(String rawBody, // Step 1: Verify signature against all known signing secrets Set signingSecrets = channelTargetRouter.getSigningSecrets("slack"); if (!signatureVerifier.verify(timestamp, rawBody, signature, signingSecrets)) { - LOGGER.warnf("Slack signature verification failed (timestamp=%s)", timestamp); + LOGGER.warnf("Slack signature verification failed (timestamp=%s)", sanitize(timestamp)); return Response.status(Response.Status.FORBIDDEN) .entity("{\"error\":\"Invalid signature\"}") .build(); From 31ca10a6fb753bf1f8b4cc13894302f7532677ca Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 17 May 2026 21:39:31 -0400 Subject: [PATCH 33/35] feat(slack): DM support, test hardening, docs overhaul - Fix silent DM drop: detect channel_type=im, add resolveDefaultForDm fallback - Extract channelType once to avoid redundant map lookups - Update SlackGroupDiscussionListener Javadoc (all styles use expanded mode) - Add App Home setup step to docs (checkbox required for DM input) - Add im:write scope and DM troubleshooting entries - Fix GroupConversationService OutputItem handling in response extraction - Add TEMPLATE_SKIP_PARAMS to LlmTask to protect secrets from template engine - Repair 8 failing tests, add 24 new tests (85 Slack tests, 0 failures) - Overhaul slack-integration.md and group-conversations.md --- docs/changelog.md | 42 ++ docs/group-conversations.md | 40 ++ docs/slack-integration.md | 383 +++++++++++------- .../internal/GroupConversationService.java | 6 +- .../channels/ChannelTargetRouter.java | 29 ++ .../integrations/slack/SlackEventHandler.java | 93 ++++- .../slack/SlackGroupDiscussionListener.java | 95 +++-- .../integrations/slack/SlackWebApiClient.java | 98 +++++ .../labs/eddi/modules/llm/impl/LlmTask.java | 8 +- .../SlackGroupDiscussionListenerTest.java | 232 ++++++----- .../slack/SlackWebApiClientTest.java | 140 ++++++- 11 files changed, 889 insertions(+), 277 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index f7795625c..d33917050 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,8 +15,50 @@ Each entry follows this format: +## Slack Integration Hardening — IM Fix, Test Repairs, Docs Overhaul (2026-05-17) + +**Repo:** EDDI (`feature/channel-integrations`) + +**What changed:** Fixed silent DM message dropping, repaired 8 broken tests, added 24 new tests for coverage, and overhauled both Slack and group-conversation documentation. + +### Bug Fix: DMs Silently Dropped + +- **Root cause:** `SlackEventHandler.handleEvent()` filtered all top-level `message` events, assuming `app_mention` handles them. But Slack never fires `app_mention` in DMs — only `message` events with `channel_type: "im"`. DMs were silently dropped. +- **Two-part fix:** + 1. `SlackEventHandler` now detects `channel_type: "im"` and lets DM messages through the filter + 2. `ChannelTargetRouter.resolveDefaultForDm()` added — DM channels use dynamic `D`-prefixed IDs that are never pre-configured, so DMs fall back to the first available Slack integration's default target + +**Files:** `SlackEventHandler.java`, `ChannelTargetRouter.java` + +### Test Repairs (8 failures → 0) + +All 8 failures caused by UX mode changes from the previous session: +- All styles now use expanded mode (`EXPANDED_STYLES` includes all 5 styles) +- Start message format changed to lowercase +- Synthesis uses header+thread pattern (2 `postMessage` calls) + +Rewrote `SlackGroupDiscussionListenerTest` to match current behavior. + +### New Test Coverage (24 new tests) + +- **`SlackWebApiClientTest`** — 19 new tests for `convertMarkdownToSlackMrkdwn` +- **`SlackGroupDiscussionListenerTest`** — 5 new tests: all styles, header+thread synthesis, start message format + +### Documentation Overhaul + +- **`slack-integration.md`** — Major rewrite: `ChannelIntegrationConfiguration` as primary config model, DM support section, unified header+thread UX, trigger keywords, Markdown→mrkdwn conversion, fixed component names, DM troubleshooting +- **`group-conversations.md`** — Added Slack Integration section: header+thread UX, all 5 styles' phase flow in Slack, trigger keywords, follow-up conversations + +### Verification + +- All Slack tests pass: 104 tests, 0 failures +- Clean compile: BUILD SUCCESS + +--- + ## Channel Integration — Second-Pass Review Fixes (2026-05-14) + **Repo:** EDDI (`feature/channel-integrations`) **What changed:** Second critical review pass, 6 additional findings fixed. diff --git a/docs/group-conversations.md b/docs/group-conversations.md index d17fef7e4..4ef22cf2f 100644 --- a/docs/group-conversations.md +++ b/docs/group-conversations.md @@ -205,9 +205,49 @@ For full control, define phases directly: | `read_group_conversation` | Read conversation transcript | | `list_group_conversations` | List past discussions | +## Slack Integration + +Group discussions integrate natively with Slack. See [slack-integration.md](slack-integration.md) for full setup instructions. + +### UX Pattern: Header + Thread + +All discussion styles use the same rendering pattern in Slack: + +1. **Start Banner** — posted in the user's thread with style name, agent count, and question +2. **Agent Headers** — each agent's first contribution is a channel-level message with a short preview +3. **Full Content** — the complete response is posted as a thread reply under the agent's header +4. **Peer Feedback** — feedback threads under the target agent's header message +5. **Revisions** — revised contributions thread under the agent's own header +6. **Synthesis** — moderator's synthesis gets its own channel-level header + thread + +### Discussion Styles in Slack + +| Style | Phase Flow in Slack | +|-------|-------------------| +| **ROUND_TABLE** | Each agent posts → Moderator synthesizes | +| **PEER_REVIEW** | Agents post → Critiques thread under targets → Revisions thread under own → Synthesis | +| **DEVIL_ADVOCATE** | Agent posts → Challenger threads challenges → Agent threads defense → Synthesis | +| **DEBATE** | PRO agent posts → CON agent posts → Rebuttals thread under opponents → Judge synthesizes | +| **DELPHI** | Round 1 agents post → Round 2 agents post (convergence) → Synthesis | + +### Trigger Keywords + +Configure trigger keywords in `ChannelIntegrationConfiguration` to route to specific groups: + +``` +@EDDI panel: Should we adopt microservices? → GROUP target "panel" +@EDDI debate: REST vs GraphQL → GROUP target "debate" +@EDDI peer: Review this architecture → GROUP target "peer" +``` + +### Follow-up Conversations + +After a discussion, users can reply in any agent's thread to ask follow-up questions. The system injects the agent's discussion context (contribution + peer feedback received) into the prompt for a contextual response. + ## Configuration ```properties # application.properties eddi.groups.max-depth=3 # Max recursion depth for nested groups ``` + diff --git a/docs/slack-integration.md b/docs/slack-integration.md index 8ae4dab17..9bfab7156 100644 --- a/docs/slack-integration.md +++ b/docs/slack-integration.md @@ -2,7 +2,7 @@ > **Status**: Production-ready · **Since**: v6.0.0 -EDDI's Slack integration enables conversational AI agents — including multi-agent group discussions — to operate natively in Slack channels. It supports 1:1 agent conversations, live-streamed panel discussions with multiple agents, and context-aware threaded follow-ups. +EDDI's Slack integration enables conversational AI agents — including multi-agent group discussions — to operate natively in Slack channels and direct messages. It supports 1:1 agent conversations, live-streamed panel discussions with multiple agents, trigger-keyword routing, and context-aware threaded follow-ups. ## Quick Setup @@ -17,10 +17,13 @@ Add these **Bot Token Scopes**: | Scope | Purpose | |-------|---------| -| `chat:write` | Post messages to channels | -| `app_mentions:read` | Respond to @mentions | +| `chat:write` | Post messages to channels and DMs | +| `app_mentions:read` | Respond to @mentions in channels | | `channels:read` | Read channel metadata | -| `im:read` | Read direct messages | +| `channels:history` | Read message events in channels | +| `im:read` | Read direct message metadata | +| `im:history` | Receive DM events | +| `im:write` | Send DM responses | ### 3. Install to Workspace @@ -33,7 +36,6 @@ Add these **Bot Token Scopes**: Store your Slack credentials in EDDI's Secrets Vault: ```bash -# Via REST API curl -X POST http://localhost:7070/secretstore/keys \ -H "Content-Type: application/json" \ -d '{"keyName":"slack-bot-token","secretValue":"xoxb-your-token-here"}' @@ -43,7 +45,55 @@ curl -X POST http://localhost:7070/secretstore/keys \ -d '{"keyName":"slack-signing-secret","secretValue":"your-signing-secret"}' ``` -### 5. Configure Channel Mapping on Your Agent +### 5. Configure Channel Integration + +There are two configuration methods. The **recommended** approach uses `ChannelIntegrationConfiguration` (new-style); the legacy `ChannelConnector` on agents is supported for backward compatibility. + +#### Recommended: ChannelIntegrationConfiguration + +Create a channel integration with trigger-keyword routing: + +```bash +curl -X POST http://localhost:7070/channelstore/channels \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Main Slack Channel", + "channelType": "slack", + "platformConfig": { + "channelId": "C0123ABCDEF", + "botToken": "${vault:slack-bot-token}", + "signingSecret": "${vault:slack-signing-secret}" + }, + "defaultTargetName": "default", + "targets": [ + { + "name": "default", + "type": "AGENT", + "targetId": "your-agent-id", + "triggers": [] + }, + { + "name": "panel", + "type": "GROUP", + "targetId": "your-group-id", + "triggers": ["panel", "group", "discuss"] + }, + { + "name": "debate", + "type": "GROUP", + "targetId": "your-debate-group-id", + "triggers": ["debate"] + } + ] + }' +``` + +With this configuration: +- `@EDDI hello` → routes to the default agent +- `@EDDI panel: Should we use microservices?` → triggers the group discussion +- `@EDDI debate: REST vs GraphQL` → triggers the debate group + +#### Legacy: ChannelConnector on Agent Add a `ChannelConnector` to your agent configuration: @@ -63,20 +113,29 @@ Add a `ChannelConnector` to your agent configuration: } ``` -The `channelId` is the Slack channel ID (find it in Slack by right-clicking a channel → **View channel details** → copy the ID at the bottom). +> **Note**: When both a `ChannelIntegrationConfiguration` and a legacy `ChannelConnector` cover the same `channelId`, the new-style config always wins. -> **Multi-workspace**: Each agent can use different bot tokens and signing secrets, allowing a single EDDI instance to serve multiple Slack workspaces. +### 6. Enable Direct Messages (App Home) -### 6. Enable Event Subscriptions in Slack +For the bot to accept DMs, you must enable the Messages Tab: -> ⚠️ **This step must come last.** When you set the Request URL, Slack immediately sends a signed `url_verification` challenge. EDDI verifies this using the signing secrets from step 6. If no agent is configured yet, verification fails and Slack rejects the URL. +1. Go to **App Home** → **Show Tabs** +2. Enable **Messages Tab** (toggle on) +3. ✅ Check **"Allow users to send Slash commands and messages from the messages tab"** + +> ⚠️ If this checkbox is unchecked, users will see "Sending messages to this app has been turned off" and cannot DM the bot. + +### 7. Enable Event Subscriptions in Slack + +> ⚠️ **This step must come last.** When you set the Request URL, Slack immediately sends a signed `url_verification` challenge. EDDI verifies this using the signing secrets from step 4. If no agent is configured yet, verification fails and Slack rejects the URL. 1. Go to **Event Subscriptions** → Enable 2. Set the **Request URL** to: `https:///integrations/slack/events` 3. Slack will verify the URL (you should see a green checkmark) 4. Subscribe to **Bot Events**: - - `app_mention` — triggers when the bot is @mentioned - - `message.im` — triggers on direct messages + - `app_mention` — triggers when the bot is @mentioned in a channel + - `message.im` — triggers on direct messages to the bot + - `message.channels` — enables thread-reply continuity without @mention 5. Click **Save Changes** --- @@ -86,25 +145,25 @@ The `channelId` is the Slack channel ID (find it in Slack by right-clicking a ch ``` Slack Workspace(s) EDDI Cluster ───────────────── ───────────────────────── -┌─────────────┐ Events API (HTTPS) ┌─────────────────────┐ -│ Slack App │ ───────────────────────→│ RestSlackWebhook │ -│ (per agent) │ │ ├─ Try all secrets │ -└─────────────┘ │ └─ Dedup events │ - └──────────┬──────────┘ - │ async - ┌──────────▼──────────┐ - │ SlackEventHandler │ - │ ├─ Route to agent │ - │ ├─ Detect group: │ - │ └─ Per-agent token │ - └──────────┬──────────┘ - │ - ┌─────────────────────┼──────────────────┐ - ▼ ▼ ▼ - ┌─────────────────┐ ┌────────────────┐ ┌───────────────┐ - │ ConversationSvc │ │ GroupConvSvc │ │ SlackWebAPI │ - │ (1:1 agent) │ │ (multi-agent) │ │ (post msgs) │ - └─────────────────┘ └────────────────┘ └───────────────┘ +┌─────────────┐ Events API (HTTPS) ┌─────────────────────────┐ +│ Slack App │ ───────────────────────→│ RestSlackWebhook │ +│ (per wksp) │ │ ├─ Try all secrets │ +└─────────────┘ │ └─ Dedup events │ + └───────────┬─────────────┘ + │ async + ┌───────────▼─────────────┐ + │ SlackEventHandler │ + │ ├─ Route via triggers │ + │ ├─ DM fallback │ + │ └─ Per-channel token │ + └───────────┬─────────────┘ + │ + ┌──────────────────────┼───────────────────┐ + ▼ ▼ ▼ + ┌─────────────────┐ ┌──────────────────┐ ┌───────────────┐ + │ ConversationSvc │ │ GroupConvSvc │ │ SlackWebAPI │ + │ (1:1 agent) │ │ (multi-agent) │ │ (post msgs) │ + └─────────────────┘ └──────────────────┘ └───────────────┘ ``` ### Key Components @@ -113,24 +172,22 @@ Slack Workspace(s) EDDI Cluster |-----------|---------------| | `RestSlackWebhook` | JAX-RS endpoint, multi-secret signature verification, URL challenge, event dispatching | | `SlackSignatureVerifier` | HMAC-SHA256 verification with multi-secret support and 5-minute replay protection | -| `SlackEventHandler` | Core event logic: message routing, group triggers, follow-up detection, per-agent bot tokens | -| `SlackChannelRouter` | Maps Slack channels → agents with full credential resolution (vault-backed) | -| `SlackGroupDiscussionListener` | Streams multi-agent discussions into Slack with two UX modes | -| `SlackWebApiClient` | Minimal HTTP client for `chat.postMessage` | +| `SlackEventHandler` | Core event logic: DM/channel routing, trigger keywords, group triggers, follow-up detection | +| `ChannelTargetRouter` | Maps Slack channels → agents/groups with trigger-keyword matching and credential resolution | +| `SlackGroupDiscussionListener` | Streams multi-agent discussions into Slack with header+thread UX | +| `SlackWebApiClient` | HTTP client for `chat.postMessage` with Markdown→mrkdwn conversion | ### Credential Flow -All credentials live in the agent's `ChannelConnector.config` map: - ``` -Agent Config → ChannelConnector.config - ├─ botToken: "${vault:slack-bot-token}" - └─ signingSecret: "${vault:slack-signing-secret}" +ChannelIntegrationConfiguration + ├─ platformConfig.botToken: "${vault:slack-bot-token}" + └─ platformConfig.signingSecret: "${vault:slack-signing-secret}" │ ▼ -SlackChannelRouter (60s cache refresh) +ChannelTargetRouter (60s cache refresh) ├─ SecretResolver resolves vault references - ├─ channelId → SlackCredentials (agentId, botToken, signingSecret, groupId) + ├─ channelType:channelId → resolved config + targets └─ allSigningSecrets set (for webhook verification) │ ├──→ RestSlackWebhook: verify(signature, allSigningSecrets) @@ -143,7 +200,7 @@ SlackChannelRouter (60s cache refresh) ### 1:1 Agent Conversations -@mention the bot in a channel or send a direct message: +@mention the bot in a channel: ``` @EDDI What's our Q4 revenue forecast? @@ -151,55 +208,95 @@ SlackChannelRouter (60s cache refresh) The bot responds in a thread under the user's message. -### Multi-Agent Group Discussions +### Direct Messages (DMs) + +Send a message directly to the bot — no @mention needed: + +``` +Hello, what can you do? +``` -Trigger with the `group:` prefix: +DMs are automatically routed to the default agent from any configured Slack integration. Since DM channel IDs are dynamic (unique per user-bot pair), they don't need explicit channel configuration — EDDI resolves to the first available Slack integration's default target. + +> **Note**: DMs use `message.im` events (Slack does not fire `app_mention` in DMs). Make sure `message.im` is subscribed in your Slack app's event settings. + +### Trigger Keywords + +Use colon-delimited trigger keywords to route to specific targets: ``` -@EDDI group: Should we adopt microservices for the payment system? +@EDDI panel: Should we adopt microservices? → routes to "panel" target +@EDDI debate: REST vs GraphQL → routes to "debate" target +@EDDI architect: Review this design → routes to "architect" target ``` -All configured agents in the group participate in a live panel discussion. +Triggers are case-insensitive. The text after the colon becomes the message sent to the target agent/group. Messages without a trigger keyword route to the default target. + +Type `@EDDI help` to see available trigger keywords for the channel. + +### Multi-Agent Group Discussions -#### UX Modes +When a trigger keyword routes to a GROUP target, a multi-agent panel discussion starts. All configured agents in the group participate in a live discussion streamed to Slack. -The UX mode is chosen automatically based on the discussion style: +#### UX Pattern: Header + Thread -| Discussion Style | UX Mode | Behavior | -|-----------------|---------|----------| -| `ROUND_TABLE` | **Compact** | All messages in a single thread | -| `DELPHI` | **Compact** | All messages in a single thread | -| `PEER_REVIEW` | **Expanded** | Each agent posts at channel level; peer feedback is threaded under the target | -| `DEVIL_ADVOCATE` | **Expanded** | Same as PEER_REVIEW | -| `DEBATE` | **Expanded** | Same as PEER_REVIEW | +All discussion styles use the same UX pattern — **header at channel level, full content in thread**: -**Compact mode** keeps things tidy — all contributions in one thread: ``` -User: @EDDI group: What's important? - └─ 🗣️ Panel Discussion (ROUND TABLE) - └─ 💬 Alice: I think... - └─ 💬 Bob: My view is... - └─ 📋 Synthesis (by Moderator): ... +User: @EDDI panel: Should we rewrite in Rust? + +🗣️ *round table discussion started* — 3 agents participating +> _Should we rewrite in Rust?_ + +🟢 *Backend Expert* +_Rust would give us memory safety and performance..._ (preview) + └─ [full response in thread] + └─ 💬 *Frontend Expert* → *Backend Expert*: I agree on safety, but... (peer feedback) + +🟢 *Frontend Expert* +_From the frontend perspective, the tooling is still maturing..._ + └─ [full response in thread] + └─ 🔄 *Frontend Expert (revised)*: After hearing feedback... (revision) + +📋 *Panel Synthesis* (by Moderator) +_The panel recommends a hybrid approach..._ (preview) + └─ [full synthesis in thread] ``` -**Expanded mode** gives each agent a channel-level post. Peer feedback threads under the target agent's post: +This pattern keeps the channel scannable while preserving full discussion detail in threads. + +#### Discussion Styles in Slack + +Each style produces a distinct phase flow, but all use the same header+thread UX: + +| Style | Phases | Slack Behavior | +|-------|--------|---------------| +| **ROUND TABLE** | Opinion → Synthesis | Each agent posts a channel header; moderator synthesizes | +| **PEER REVIEW** | Opinion → Critique → Revision → Synthesis | Peer feedback threads under the target agent's header | +| **DEVIL'S ADVOCATE** | Opinion → Challenge → Defense → Synthesis | Challenger threads under the original agent's header | +| **DEBATE** | Pro Arguments → Con Arguments → Rebuttals → Judge | PRO and CON agents post separate headers; rebuttals thread under opponents | +| **DELPHI** | Anonymous Round 1 → Round 2 (convergence) → Synthesis | Each round's opinions post as headers; convergence visible across rounds | + +#### Peer Feedback Threading + +In styles with agent-to-agent feedback (PEER_REVIEW, DEVIL_ADVOCATE, DEBATE), feedback is posted as a **thread reply under the target agent's channel header**. This creates a natural conversation flow: + ``` -User: @EDDI group: Should we rewrite? -💬 Alice: I believe we should... - └─ 🔍 Bob → Alice: I disagree because... - └─ 🔍 Carol → Alice: I agree, and also... -💬 Bob: My position is... - └─ ✏️ Bob (revised): After hearing feedback... -📋 Synthesis (by Moderator): ... +🟢 *Alice* ← channel-level header + └─ I believe we should... ← full response (thread) + └─ 💬 *Bob* → *Alice*: I disagree because... ← peer feedback (thread) + └─ 💬 *Carol* → *Alice*: I agree, and also... ← peer feedback (thread) + └─ 🔄 *Alice (revised)*: After hearing feedback... ← revision (thread) ``` ### Context-Aware Follow-ups -After an expanded-mode discussion, users can reply in an agent's thread to ask follow-up questions: +After a discussion, users can reply in an agent's thread to ask follow-up questions: ``` -Alice's original post: "I believe microservices would help..." - └─ Bob → Alice: "I disagree because..." +Alice's header: 🟢 *Alice* + └─ [original contribution] + └─ 💬 Bob → Alice: I disagree... └─ User: "Alice, can you address Bob's concerns?" └─ Alice: [responds with full context of the discussion + peer feedback] ``` @@ -210,13 +307,26 @@ The follow-up system: 3. Injects that context into the prompt 4. Routes to the correct agent for a contextual response +### Markdown Conversion + +Agent responses often contain standard Markdown. The `SlackWebApiClient` automatically converts to Slack's `mrkdwn` format at the egress point: + +| Markdown | Slack mrkdwn | +|----------|-------------| +| `**bold**` | `*bold*` | +| `# Heading` | `*Heading*` (bold) | +| `~~strike~~` | `~strike~` | +| `---` | `───────────` (Unicode line) | +| Tables (`\| col \|`) | Wrapped in `` ``` `` code blocks | +| Code blocks | Preserved unchanged | + --- ## Enterprise & Clustering ### Multi-Workspace Support -Each agent can connect to a different Slack workspace by using different bot tokens and signing secrets. The `SlackChannelRouter` caches all credentials and the `SlackSignatureVerifier` tries all known signing secrets during webhook verification. +Each `ChannelIntegrationConfiguration` can use different bot tokens and signing secrets, allowing a single EDDI instance to serve multiple Slack workspaces. The `ChannelTargetRouter` caches all credentials and the `SlackSignatureVerifier` tries all known signing secrets during webhook verification. ### Retry Logic @@ -232,7 +342,7 @@ Active group discussion contexts use EDDI's `ICache` infrastructure with **TTL-b ### Thread Safety -- `SlackChannelRouter` uses volatile reference swaps with an `AtomicBoolean` refresh gate — no thundering herd on cache expiry +- `ChannelTargetRouter` uses volatile reference swaps with an `AtomicBoolean` refresh gate — no thundering herd on cache expiry - Event processing runs on virtual threads — non-blocking, scales to thousands of concurrent events - The `CountDownLatch` in `SlackGroupDiscussionListener` signals completion cleanly without polling @@ -252,9 +362,48 @@ When running EDDI as a multi-instance cluster behind a load balancer: ## Configuration Reference -### Per-Agent ChannelConnector Config +### ChannelIntegrationConfiguration (Recommended) -Configure on each agent's `channels[]` array: +```json +{ + "name": "Production Slack", + "channelType": "slack", + "platformConfig": { + "channelId": "C0123ABCDEF", + "botToken": "${vault:slack-bot-token}", + "signingSecret": "${vault:slack-signing-secret}" + }, + "defaultTargetName": "default", + "targets": [ + { + "name": "default", + "type": "AGENT", + "targetId": "agent-id", + "triggers": [] + }, + { + "name": "panel", + "type": "GROUP", + "targetId": "group-id", + "triggers": ["panel", "group"] + } + ] +} +``` + +| Key | Required | Description | +|-----|----------|-------------| +| `channelType` | ✅ | Must be `"slack"` | +| `platformConfig.channelId` | ✅ | Slack channel ID (e.g., `C0123ABCDEF`) | +| `platformConfig.botToken` | ✅ | Bot User OAuth Token. Use vault reference. | +| `platformConfig.signingSecret` | ✅ | Slack Signing Secret. Use vault reference. | +| `defaultTargetName` | ✅ | Name of the target used when no trigger keyword matches | +| `targets[].name` | ✅ | Target name (must match `defaultTargetName` for the default) | +| `targets[].type` | ✅ | `AGENT` or `GROUP` | +| `targets[].targetId` | ✅ | Agent ID or Group Config ID | +| `targets[].triggers` | ❌ | List of trigger keywords (case-insensitive) | + +### Legacy ChannelConnector (on Agent) ```json { @@ -268,13 +417,6 @@ Configure on each agent's `channels[]` array: } ``` -| Key | Required | Description | -|-----|----------|-------------| -| `channelId` | ✅ | Slack channel ID (e.g., `C0123ABCDEF`) | -| `botToken` | ✅ | Bot User OAuth Token (`xoxb-...`). Use vault reference. | -| `signingSecret` | ✅ | Slack Signing Secret for request verification. Use vault reference. | -| `groupId` | ❌ | Group config ID for multi-agent discussions | - --- ## Retry & Error Handling @@ -324,14 +466,23 @@ During a multi-agent group discussion, individual Slack post failures do **not** | Check | Fix | |-------|-----| -| Bot token configured? | Check agent's ChannelConnector config — `botToken` should reference a vault key | +| Integration configured? | Create a `ChannelIntegrationConfiguration` with the channel's `channelId` | +| Bot token configured? | `platformConfig.botToken` should reference a vault key | | Bot in channel? | Invite the bot to the channel in Slack | | Event subscription active? | Check **Event Subscriptions** in Slack app settings | | Request URL verified? | Slack must have verified `https:///integrations/slack/events` | -| EDDI accessible? | The URL must be publicly reachable (or via tunnel for dev) | -| Channel mapped? | Check the agent's `ChannelConnector` has the correct `channelId` | | Signing secret set? | Without a signing secret, webhook verification fails (HTTP 403) | +### Bot doesn't respond to DMs + +| Check | Fix | +|-------|-----| +| "Sending messages has been turned off"? | **App Home** → Messages Tab → ✅ check "Allow users to send Slash commands and messages" | +| `message.im` subscribed? | Add `message.im` to Bot Events in Slack app settings | +| `im:history` scope? | Add `im:history` to Bot Token Scopes and reinstall the app | +| `im:write` scope? | Add `im:write` to Bot Token Scopes and reinstall the app | +| Any Slack integration configured? | DMs fall back to the first available Slack integration's default target | + ### Signature verification fails (HTTP 403) | Check | Fix | @@ -339,15 +490,7 @@ During a multi-agent group discussion, individual Slack post failures do **not** | Signing secret correct? | Copy from **Basic Information** in Slack app settings, store in vault | | Clock drift? | Timestamp validation uses 5-minute window — sync clocks | | Reverse proxy stripping body? | The raw body must reach EDDI unchanged for HMAC verification | -| No agents configured? | At least one deployed agent must have a Slack ChannelConnector with `signingSecret` | - -### `No agent configured for this channel` - -The bot responds but says no agent is mapped. Add a `ChannelConnector` with the correct `channelId` to your agent config. - -### `No group configured for this channel` - -Triggered by `group:` prefix but no group mapped. Add `groupId` to the channel's `ChannelConnector` config. +| No agents configured? | At least one deployed agent must have a Slack integration with `signingSecret` | ### Messages appear duplicated @@ -384,58 +527,15 @@ Every channel integration follows the same layered pattern: │ ┌──────────▼──────────┐ │ Channel Router │ ← Map platform IDs → EDDI agents + credentials -│ (SlackChannelRouter)│ Scan ChannelConnector configs, resolve vault refs +│ (ChannelTargetRouter)│ Trigger-keyword matching, vault-backed secrets └──────────┬──────────┘ │ ┌──────────▼──────────┐ │ API Client │ ← Platform's outgoing API (send messages) -│ (SlackWebApiClient) │ Retryable exceptions, proper JSON handling +│ (SlackWebApiClient) │ Retryable exceptions, Markdown→mrkdwn conversion └──────────────────────┘ ``` -### Step-by-Step Implementation Guide - -#### 1. Create a `ChannelType` entry - -Add your platform type (e.g., `teams`, `discord`) to the `ChannelConnector.type` field convention. This is a URI string, not an enum — just use the platform name. - -#### 2. Webhook Endpoint (`Rest*Webhook.java`) - -- **Must respond within the platform's timeout** (Slack: 3s, Discord: 3s, Teams: 15s) -- **Verify request authenticity** (HMAC signature, token, etc.) -- **Process async** — dispatch to a handler on a virtual thread -- **Return immediately** with HTTP 200 or platform-specific acknowledgment -- **Dedup events** — most platforms retry on timeout - -#### 3. Event Handler (`*EventHandler.java`) - -- Use `IConversationService` for 1:1 agent conversations -- Use `IGroupConversationService` for multi-agent discussions -- Use `IUserConversationStore` to map platform thread IDs → EDDI conversations -- Use `ICacheFactory.getCache(name, Duration)` for dedup and session caches (always use TTL!) - -#### 4. Channel Router (`*ChannelRouter.java`) - -- Scan `AgentConfiguration.getChannels()` for your platform type -- Cache the mapping with time-based refresh (60s is good) -- Use `AtomicBoolean` for refresh gating (prevent thundering herd) -- Use `SecretResolver` to resolve vault references for all credentials -- Store per-agent credentials alongside the routing map - -#### 5. API Client (`*ApiClient.java`) - -- **Throw exceptions for retryable failures** (network, rate limit, server errors) -- **Return null for non-retryable API failures** (bad channel, bad token) -- Use Jackson `ObjectMapper` for JSON serialization (not manual string building) -- Use Jackson for response parsing (not string indexOf) - -#### 6. Group Discussion Listener (`*GroupDiscussionListener.java`) - -- Implement `GroupDiscussionEventListener` -- Use a `postSafe()` wrapper — never let a single failed post abort the discussion -- Track agent message IDs for follow-up routing (reverse map for O(1) lookups) -- Signal completion via `CountDownLatch` (not polling) - ### Key Lessons from the Slack Implementation | Lesson | Why | @@ -449,3 +549,4 @@ Add your platform type (e.g., `teams`, `discord`) to the `ChannelConnector.type` | **Structured exhaustion logs** | After retry exhaustion, log enough context (channel, thread, text length, error) for operator recovery. | | **Never leak internal IDs to users** | Error messages should be generic. Log the details server-side. | | **All credentials in config** | Per-channel credentials via vault references. No server-level secrets. | +| **Convert formatting at the egress point** | Markdown→mrkdwn conversion in the API client ensures consistent rendering across all code paths. | diff --git a/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java b/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java index 9a534250e..a82d7f0ad 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java +++ b/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java @@ -28,6 +28,7 @@ import ai.labs.eddi.engine.api.IGroupConversationService; import ai.labs.eddi.engine.model.Context; import ai.labs.eddi.engine.model.Deployment.Environment; +import ai.labs.eddi.modules.output.model.OutputItem; import ai.labs.eddi.engine.model.InputData; import ai.labs.eddi.engine.runtime.IAgentFactory; import ai.labs.eddi.modules.templating.ITemplatingEngine; @@ -918,12 +919,15 @@ private String extractResponse(ai.labs.eddi.engine.memory.model.SimpleConversati var texts = new ArrayList(); - // Format 1: Nested "output" array — [{type: "text", text: "...", delay: 0}] + // Format 1: Nested "output" array — may contain TextOutputItem POJOs or Maps Object outputArray = lastOutput.get("output"); if (outputArray instanceof List list) { for (var item : list) { if (item instanceof String s) { texts.add(s); + } else if (item instanceof OutputItem oi && oi.toString() != null) { + // TextOutputItem.toString() returns the text field + texts.add(oi.toString()); } else if (item instanceof Map map) { Object text = map.get("text"); if (text instanceof String s) { diff --git a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java index fae6d0545..b7c19dceb 100644 --- a/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -142,6 +142,35 @@ public ResolvedTarget resolveTarget(String channelType, String platformChannelId return null; // No integration for this channel } + /** + * Resolve a default target for DMs or unconfigured channels. Used when the + * platform channel ID isn't explicitly configured (e.g., Slack DMs use dynamic + * D-prefixed IDs unique to each user-bot pair). + *

    + * Returns the default target from the first available integration of the given + * channel type, or {@code null} if no integrations exist. + */ + public ResolvedTarget resolveDefaultForDm(String channelType, String messageText) { + refreshIfNeeded(); + String prefix = (channelType != null ? channelType.toLowerCase(Locale.ROOT) : "") + ":"; + for (var entry : integrationMap.entrySet()) { + if (entry.getKey().startsWith(prefix)) { + return resolveFromIntegration(entry.getValue(), messageText); + } + } + // Fallback to first legacy entry (Slack only) + if (CHANNEL_TYPE_SLACK.equals(channelType) && !legacyMap.isEmpty()) { + var firstLegacy = legacyMap.values().iterator().next(); + String trimmed = messageText != null ? messageText.trim() : ""; + if (trimmed.isEmpty() || "help".equalsIgnoreCase(trimmed)) { + return null; + } + return new ResolvedTarget(firstLegacy.toChannelTarget(), messageText, null, + firstLegacy.botToken(), firstLegacy.signingSecret()); + } + return null; + } + /** * Resolve the target for a thread reply using the thread→target lock. * diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java index eea440cda..0deddc2c5 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -17,6 +17,7 @@ import ai.labs.eddi.engine.triggermanagement.model.UserConversation; import ai.labs.eddi.integrations.channels.ChannelTargetRouter; import ai.labs.eddi.integrations.channels.ChannelTargetRouter.ResolvedTarget; +import ai.labs.eddi.modules.output.model.OutputItem; import ai.labs.eddi.datastore.IResourceStore; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; @@ -161,12 +162,46 @@ public void handleEventAsync(String eventId, Map event) { } private void handleEvent(Map event) throws Exception { + String eventType = (String) event.get("type"); + String eventSubtype = (String) event.get("subtype"); + String eventChannel = (String) event.get("channel"); + String eventThreadTs = (String) event.get("thread_ts"); + String textPreview = event.get("text") instanceof String t ? (t.length() > 50 ? t.substring(0, 50) + "..." : t) : "null"; + LOGGER.infof("[SLACK] Event received: type=%s, subtype=%s, channel=%s, thread_ts=%s, has_bot_id=%s, text=%s", + sanitize(eventType), sanitize(eventSubtype), sanitize(eventChannel), + sanitize(eventThreadTs), event.containsKey("bot_id"), + sanitize(textPreview)); + // Filter bot's own messages (prevent infinite loop) if (event.containsKey("bot_id") || "bot_message".equals(event.get("subtype"))) { - LOGGER.debugf("Ignoring bot message in channel %s", sanitize(String.valueOf(event.get("channel")))); + LOGGER.debugf("[SLACK] Ignoring bot message in channel %s", sanitize(String.valueOf(event.get("channel")))); return; } + // Extract once — used for DM detection in both the message filter and + // the DM fallback resolution (step 4 below) + String channelType = (String) event.get("channel_type"); + boolean isDirectMessage = "im".equals(channelType); + + // For "message" events (from message.channels/groups/im subscriptions): + // - DMs (channel_type: "im") → always process (no app_mention in DMs) + // - Top-level channel messages → handled by app_mention, skip here + // - Thread replies with @mention → handled by app_mention, skip here + // - Thread replies without @mention → process here (thread continuity) + if ("message".equals(eventType)) { + if (eventThreadTs == null && !isDirectMessage) { + // Top-level channel message — only app_mention should handle these + LOGGER.debugf("[SLACK] Ignoring top-level message event (use @mention)"); + return; + } + String text = (String) event.get("text"); + if (text != null && BOT_MENTION_PATTERN.matcher(text).find()) { + // Thread reply with @mention — app_mention event will handle it + LOGGER.debugf("[SLACK] Ignoring @mentioned thread reply (handled by app_mention)"); + return; + } + } + String text = (String) event.get("text"); String userId = (String) event.get("user"); String channelId = (String) event.get("channel"); @@ -205,6 +240,13 @@ && tryHandleAgentFollowUp(parentTs, channelId, userId, text, threadTs)) { resolved = channelTargetRouter.resolveTarget("slack", channelId, text); } + // 4. DM fallback: if no explicit config for this channel (DMs use dynamic + // D-prefixed IDs), fall back to any configured Slack integration's default + // target + if (resolved == null && isDirectMessage) { + resolved = channelTargetRouter.resolveDefaultForDm("slack", text); + } + if (resolved == null) { postHelp(channelId, threadTs, null); return; @@ -426,7 +468,9 @@ private String sendAndWait(String conversationId, String message) throws Excepti } /** - * Extract the text response from a conversation snapshot. + * Extract the text response from a conversation snapshot. Handles output items + * stored as {@link OutputItem} POJOs (live memory callback path) or as Maps + * (deserialized from MongoDB). */ private String extractResponseText(SimpleConversationMemorySnapshot snapshot) { var outputs = snapshot.getConversationOutputs(); @@ -435,12 +479,23 @@ private String extractResponseText(SimpleConversationMemorySnapshot snapshot) { } var lastOutput = outputs.get(outputs.size() - 1); - var outputItems = lastOutput.get("output"); - if (outputItems instanceof List items) { - var texts = new ArrayList(); - for (var item : items) { - if (item instanceof Map map && map.containsKey("text")) { - texts.add(String.valueOf(map.get("text"))); + if (lastOutput == null) { + return "_No response from agent._"; + } + + var texts = new ArrayList(); + + // Format 1: Nested "output" array — may contain TextOutputItem POJOs or Maps + Object outputArray = lastOutput.get("output"); + if (outputArray instanceof List list) { + for (var item : list) { + if (item instanceof String s) { + texts.add(s); + } else if (item instanceof OutputItem oi && oi.toString() != null) { + // TextOutputItem.toString() returns the text field + texts.add(oi.toString()); + } else if (item instanceof Map map && map.get("text") instanceof String s) { + texts.add(s); } } if (!texts.isEmpty()) { @@ -448,6 +503,28 @@ private String extractResponseText(SimpleConversationMemorySnapshot snapshot) { } } + // Format 2: Flat keys like "output:text:agent" or "output:text:*" + for (var entry : lastOutput.entrySet()) { + if (entry.getKey() instanceof String key && key.startsWith("output:text:")) { + Object val = entry.getValue(); + if (val instanceof String s) { + texts.add(s); + } else if (val instanceof List list) { + for (var item : list) { + if (item instanceof String s) { + texts.add(s); + } else if (item instanceof Map map && map.get("text") instanceof String s) { + texts.add(s); + } + } + } + } + } + + if (!texts.isEmpty()) { + return String.join("\n", texts); + } + return "_Agent completed but produced no text output._"; } diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java index ceac983e7..2e2f8c2f7 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListener.java @@ -19,15 +19,14 @@ * Implements {@link GroupDiscussionEventListener} to receive callbacks as * agents speak, and posts each contribution to Slack. *

    - * Two UX modes based on discussion style: - *

      - *
    • COMPACT (ROUND_TABLE, DELPHI) — all messages in a single thread - * under the user's original message. Clean and contained.
    • - *
    • EXPANDED (PEER_REVIEW, DEVIL_ADVOCATE, DEBATE) — each agent's - * primary contribution is a channel-level message. Peer feedback is posted as a - * thread reply under the target agent's message. Revisions thread under the - * agent's own original message.
    • - *
    + * All discussion styles use EXPANDED mode: each agent's first + * contribution is a channel-level header with a short preview, and the full + * response lives in a thread reply. Peer feedback threads under the target + * agent's message; revisions thread under the agent's own message. + *

    + * Compact mode code paths remain as a safety net for potential future styles + * but are currently unreachable ({@code EXPANDED_STYLES} contains all 5 + * styles). * * @since 6.0.0 */ @@ -37,9 +36,11 @@ public class SlackGroupDiscussionListener implements GroupDiscussionEventListene /** * Discussion styles that use EXPANDED mode (channel-level messages with peer - * threading). + * threading). All styles use expanded mode in Slack for readability — compact + * mode (single thread) is too hard to follow with multiple agents. */ - private static final Set EXPANDED_STYLES = Set.of("PEER_REVIEW", "DEVIL_ADVOCATE", "DEBATE"); + private static final Set EXPANDED_STYLES = Set.of( + "ROUND_TABLE", "PEER_REVIEW", "DEVIL_ADVOCATE", "DEBATE", "DELPHI"); private final SlackWebApiClient slackApi; private final String authToken; @@ -91,10 +92,10 @@ public void onGroupStart(GroupConversationEventSink.GroupStartEvent event) { this.groupConversationId = event.groupConversationId(); this.expandedMode = EXPANDED_STYLES.contains(event.style()); - String modeLabel = expandedMode ? "threaded" : "compact"; - String msg = String.format("🗣️ *Starting %s discussion* (%s mode, %d agents)\n_%s_", - event.style().replace("_", " "), modeLabel, - event.memberAgentIds().size(), event.question()); + String styleName = event.style().replace("_", " ").toLowerCase(); + String msg = String.format( + "🗣️ *%s discussion started* — %d agents participating\n\n> _%s_", + styleName, event.memberAgentIds().size(), event.question()); // Always post the start message in the user's thread postSafe(channelId, userThreadTs, msg); @@ -154,10 +155,7 @@ public void onSpeakerComplete(GroupConversationEventSink.SpeakerCompleteEvent ev @Override public void onSynthesisStart(GroupConversationEventSink.SynthesisStartEvent event) { isSynthesisPhase = true; - if (expandedMode) { - // Visual separator before synthesis in the channel - postSafe(channelId, null, "───────────────────────────"); - } + // No separator needed — the synthesis header stands out on its own } @Override @@ -193,21 +191,56 @@ public void onGroupError(GroupConversationEventSink.GroupErrorEvent event) { // ─── Posting strategies ─── /** - * Post an agent's first contribution as a channel-level message (EXPANDED - * mode). Saves the message ts for future threading. + * Post an agent's first contribution as a channel-level header with the full + * response as a thread reply (EXPANDED mode). This prevents long agent + * responses from flooding the channel — the header shows agent name and a brief + * preview, while the full content lives in the thread. */ private void postPrimaryContribution(GroupConversationEventSink.SpeakerCompleteEvent event) { String displayName = event.displayName() != null ? event.displayName() : event.agentId(); - String msg = String.format("🟢 *%s*\n%s", displayName, event.response()); + String response = event.response(); + + // Build a short preview for the channel-level header (first meaningful line, + // truncated) + String preview = buildPreview(response, 150); + String header = String.format("🟢 *%s*\n_%s_", displayName, preview); - String ts = postSafe(channelId, null, msg); + String ts = postSafe(channelId, null, header); if (ts != null) { agentMessageTs.put(event.agentId(), ts); messageTsToAgentId.put(ts, event.agentId()); + + // Post the full response as a thread reply + postSafe(channelId, ts, response); LOGGER.debugf("Tracked agent %s message ts=%s", event.agentId(), ts); } } + /** + * Build a short preview from a response — first non-empty, non-heading line, + * truncated to maxLength. + */ + private static String buildPreview(String text, int maxLength) { + if (text == null || text.isBlank()) { + return "…"; + } + for (String line : text.split("\n")) { + String trimmed = line.trim(); + // Skip empty lines, headings, separators, code fences + if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("```") + || trimmed.matches("^[-─═*_]{3,}$")) { + continue; + } + // Strip markdown bold for cleaner preview + trimmed = trimmed.replaceAll("\\*\\*(.+?)\\*\\*", "$1"); + if (trimmed.length() > maxLength) { + return trimmed.substring(0, maxLength) + "…"; + } + return trimmed; + } + return text.substring(0, Math.min(text.length(), maxLength)) + "…"; + } + /** * Post peer feedback as a thread reply under the target agent's channel * message. @@ -258,17 +291,25 @@ private void postRevision(GroupConversationEventSink.SpeakerCompleteEvent event) } /** - * Post the synthesis — always prominent and visible. + * Post the synthesis — prominent header at channel level with full content in + * thread. The synthesis is the final deliverable of the discussion. */ private void postSynthesis(String displayName, String response) { synthesisPosted = true; isSynthesisPhase = false; - String msg = String.format("📋 *Synthesis* (by %s)\n%s", displayName, response); + + String preview = buildPreview(response, 200); + String header = String.format("📋 *Panel Synthesis* (by %s)\n_%s_", displayName, preview); if (expandedMode) { - postSafe(channelId, null, msg); + String ts = postSafe(channelId, null, header); + if (ts != null) { + // Full synthesis in thread + postSafe(channelId, ts, response); + } } else { - postSafe(channelId, userThreadTs, msg); + postSafe(channelId, userThreadTs, header); + postSafe(channelId, userThreadTs, response); } } diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackWebApiClient.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackWebApiClient.java index 3582c8aa2..f007047a4 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackWebApiClient.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackWebApiClient.java @@ -76,6 +76,9 @@ public SlackWebApiClient(ObjectMapper objectMapper) { */ public String postMessage(String authToken, String channelId, String threadTs, String text) { try { + // Convert standard Markdown to Slack mrkdwn format + text = convertMarkdownToSlackMrkdwn(text); + // Build JSON body using Jackson for proper escaping (handles all // Unicode control characters, surrogate pairs, etc.) Map body = new LinkedHashMap<>(); @@ -137,6 +140,101 @@ public String postMessage(String authToken, String channelId, String threadTs, S } } + /** + * Convert standard Markdown to Slack mrkdwn format. + *

    + * Key differences handled: + *

      + *
    • {@code **bold**} → {@code *bold*}
    • + *
    • {@code # Heading} → {@code *Heading*} (bold, no heading support)
    • + *
    • {@code ~~strike~~} → {@code ~strike~}
    • + *
    • Markdown tables → code blocks (Slack has no table support)
    • + *
    • Horizontal rules ({@code ---}) → Unicode line
    • + *
    + * Code blocks (``` fenced) are preserved untouched. + */ + static String convertMarkdownToSlackMrkdwn(String text) { + if (text == null || text.isEmpty()) { + return text; + } + + String[] lines = text.split("\n", -1); + var result = new StringBuilder(); + boolean inCodeBlock = false; + boolean inTable = false; + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + + // Toggle code block state — preserve code blocks untouched + if (line.trim().startsWith("```")) { + inCodeBlock = !inCodeBlock; + if (inTable) { + // Close any open table-as-code-block before the actual code block + inTable = false; + } + result.append(line).append("\n"); + continue; + } + + if (inCodeBlock) { + result.append(line).append("\n"); + continue; + } + + // Detect markdown table rows (| col | col |) + if (line.trim().startsWith("|") && line.trim().endsWith("|")) { + // Skip separator rows (|---|---|) + if (line.matches("^\\s*\\|[-:\\s|]+\\|\\s*$")) { + continue; + } + if (!inTable) { + inTable = true; + result.append("```\n"); + } + // Clean up the table row for monospace display + String cleaned = line.replaceAll("\\*\\*(.+?)\\*\\*", "$1"); // remove bold in tables + result.append(cleaned).append("\n"); + continue; + } else if (inTable) { + // End of table + inTable = false; + result.append("```\n"); + } + + // Convert headings: # Heading → *Heading* + if (line.matches("^#{1,6}\\s+.*")) { + line = line.replaceFirst("^#{1,6}\\s+", ""); + line = "*" + line.trim() + "*"; + } + + // Convert bold: **text** → *text* + line = line.replaceAll("\\*\\*(.+?)\\*\\*", "*$1*"); + + // Convert strikethrough: ~~text~~ → ~text~ + line = line.replaceAll("~~(.+?)~~", "~$1~"); + + // Convert horizontal rules + if (line.matches("^\\s*[-*_]{3,}\\s*$")) { + line = "───────────────────────────"; + } + + result.append(line).append("\n"); + } + + // Close any dangling table block + if (inTable) { + result.append("```\n"); + } + + // Remove trailing newline + if (!result.isEmpty() && result.charAt(result.length() - 1) == '\n') { + result.setLength(result.length() - 1); + } + + return result.toString(); + } + private static String truncateForLog(String text) { if (text == null) return ""; diff --git a/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java b/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java index dc0d0f730..e95c8e7a4 100644 --- a/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java +++ b/src/main/java/ai/labs/eddi/modules/llm/impl/LlmTask.java @@ -549,12 +549,18 @@ private void executeTask(IConversationMemory memory, Task task, IWritableConvers } } + /** + * Parameters that should NOT be processed by the template engine (credentials, + * secrets). + */ + private static final Set TEMPLATE_SKIP_PARAMS = Set.of("apiKey", "signingSecret", "appPassword", "botToken"); + private HashMap runTemplateEngineOnParams(Map parameters, Map templateDataObjects) { var processedParams = new HashMap<>(parameters); processedParams.forEach((key, value) -> { try { - if (!isNullOrEmpty(value)) { + if (!isNullOrEmpty(value) && !TEMPLATE_SKIP_PARAMS.contains(key)) { processedParams.put(key, templatingEngine.processTemplate(value, templateDataObjects)); } } catch (ITemplatingEngine.TemplateEngineException e) { diff --git a/src/test/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListenerTest.java b/src/test/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListenerTest.java index e40810cd0..c36a91232 100644 --- a/src/test/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListenerTest.java +++ b/src/test/java/ai/labs/eddi/integrations/slack/SlackGroupDiscussionListenerTest.java @@ -16,6 +16,9 @@ /** * Tests for {@link SlackGroupDiscussionListener}. + *

    + * All discussion styles now use EXPANDED mode (channel-level messages with + * per-agent threads). There is no compact mode. */ class SlackGroupDiscussionListenerTest { @@ -32,154 +35,183 @@ void setUp() { listener = new SlackGroupDiscussionListener(slackApi, AUTH_TOKEN, CHANNEL, USER_THREAD); } - // ─── UX Mode Detection ─── + // ─── UX Mode Detection — all styles use expanded ─── @Test - void onGroupStart_peerReview_setsExpandedMode() { - listener.onGroupStart(new GroupConversationEventSink.GroupStartEvent( - "gc1", "g1", "Test question", "PEER_REVIEW", 3, List.of("a1", "a2"))); - + void onGroupStart_roundTable_setsExpandedMode() { + listener.onGroupStart(groupStart("ROUND_TABLE", 2)); assertTrue(listener.isExpandedMode()); - verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), contains("PEER REVIEW")); + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), + contains("round table")); } @Test - void onGroupStart_roundTable_setsCompactMode() { - listener.onGroupStart(new GroupConversationEventSink.GroupStartEvent( - "gc1", "g1", "Test question", "ROUND_TABLE", 2, List.of("a1", "a2"))); - - assertFalse(listener.isExpandedMode()); - verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), contains("ROUND TABLE")); + void onGroupStart_peerReview_setsExpandedMode() { + listener.onGroupStart(groupStart("PEER_REVIEW", 3)); + assertTrue(listener.isExpandedMode()); + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), + contains("peer review")); } @Test void onGroupStart_debate_setsExpandedMode() { - listener.onGroupStart(new GroupConversationEventSink.GroupStartEvent( - "gc1", "g1", "Question?", "DEBATE", 3, List.of("a1", "a2", "a3"))); - + listener.onGroupStart(groupStart("DEBATE", 3)); assertTrue(listener.isExpandedMode()); } @Test - void onGroupStart_delphi_setsCompactMode() { - listener.onGroupStart(new GroupConversationEventSink.GroupStartEvent( - "gc1", "g1", "Question?", "DELPHI", 3, List.of("a1"))); - - assertFalse(listener.isExpandedMode()); + void onGroupStart_devilAdvocate_setsExpandedMode() { + listener.onGroupStart(groupStart("DEVIL_ADVOCATE", 2)); + assertTrue(listener.isExpandedMode()); + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), + contains("devil advocate")); } - // ─── Compact Mode (ROUND_TABLE) ─── - @Test - void compactMode_contributions_postedInThread() { - initCompactMode(); - - listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "My opinion...", null, null)); - listener.onSpeakerComplete(speakerEvent("agent2", "Bob", "I think...", null, null)); - - // Both posted in the user's thread - verify(slackApi, times(3)).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), any()); + void onGroupStart_delphi_setsExpandedMode() { + listener.onGroupStart(groupStart("DELPHI", 3)); + assertTrue(listener.isExpandedMode()); } @Test - void compactMode_peerFeedback_postedInThread_withArrow() { - initCompactMode(); - - listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "My opinion...", null, null)); - listener.onSpeakerComplete(speakerEvent("agent2", "Bob", "I disagree because...", "agent1", "Alice")); + void onGroupStart_messageContainsAgentCount() { + listener.onGroupStart(groupStart("ROUND_TABLE", 2)); + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), + contains("2 agents")); + } - // Feedback includes arrow notation - verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), contains("→")); + @Test + void onGroupStart_messageContainsQuestionInBlockquote() { + listener.onGroupStart(new GroupConversationEventSink.GroupStartEvent( + "gc1", "g1", "What is EDDI?", "ROUND_TABLE", 2, List.of("a1", "a2"))); + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), + contains("> _What is EDDI?_")); } - // ─── Expanded Mode (PEER_REVIEW) ─── + // ─── Primary Contributions (EXPANDED mode) ─── @Test - void expandedMode_firstContribution_postedAsChannelMessage() { - initExpandedMode(); + void expandedMode_firstContribution_postsHeaderAndThread() { + initExpanded(); when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Alice"))) - .thenReturn("1234567890.001"); + .thenReturn("ts-alice"); listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "My opinion...", null, null)); - // Posted at channel level (null threadTs) + // Header at channel level verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Alice")); + // Full response in thread + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq("ts-alice"), eq("My opinion...")); // ts is tracked - assertEquals("1234567890.001", listener.getAgentMessageTsMap().get("agent1")); + assertEquals("ts-alice", listener.getAgentMessageTsMap().get("agent1")); + } + + @Test + void expandedMode_secondAgent_postsOwnHeader() { + initExpanded(); + when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Alice"))) + .thenReturn("ts-alice"); + when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Bob"))) + .thenReturn("ts-bob"); + + listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "Opinion A", null, null)); + listener.onSpeakerComplete(speakerEvent("agent2", "Bob", "Opinion B", null, null)); + + assertEquals("ts-alice", listener.getAgentMessageTsMap().get("agent1")); + assertEquals("ts-bob", listener.getAgentMessageTsMap().get("agent2")); } + // ─── Peer Feedback ─── + @Test void expandedMode_peerFeedback_postedUnderTargetMessage() { - initExpandedMode(); + initExpanded(); when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Alice"))) - .thenReturn("1234567890.001"); + .thenReturn("ts-alice"); - // Alice posts first listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "My opinion...", null, null)); - // Bob reviews Alice listener.onSpeakerComplete(speakerEvent("agent2", "Bob", "I disagree...", "agent1", "Alice")); // Bob's feedback threads under Alice's message - verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq("1234567890.001"), + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq("ts-alice"), contains("Bob")); } @Test - void expandedMode_revision_threadsUnderOwnMessage() { - initExpandedMode(); + void expandedMode_peerFeedback_containsArrowNotation() { + initExpanded(); when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Alice"))) - .thenReturn("1234567890.001"); + .thenReturn("ts-alice"); - // Alice posts first (channel-level) listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "My opinion...", null, null)); - // Alice posts again (revision — threads under own message) - listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "Revised opinion...", null, null)); + listener.onSpeakerComplete(speakerEvent("agent2", "Bob", "I disagree...", "agent1", "Alice")); - // Revision threads under Alice's own message - verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq("1234567890.001"), - contains("revised")); + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq("ts-alice"), + contains("→")); } @Test void expandedMode_peerFeedback_fallbackToThread_whenNoTargetTs() { - initExpandedMode(); + initExpanded(); // No agent2 message posted yet, so no ts in map - listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "Feedback...", "agent2", "Bob")); // Falls back to user thread since agent2 has no ts verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), contains("Alice")); } - // ─── Synthesis ─── + // ─── Revisions ─── @Test - void expandedMode_synthesis_postedAtChannelLevel() { - initExpandedMode(); - listener.onSynthesisStart(new GroupConversationEventSink.SynthesisStartEvent("moderator1")); + void expandedMode_revision_threadsUnderOwnMessage() { + initExpanded(); + when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Alice"))) + .thenReturn("ts-alice"); - listener.onSpeakerComplete(speakerEvent("moderator1", "Moderator", "The panel agrees...", null, null)); + listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "My opinion...", null, null)); + listener.onSpeakerComplete(speakerEvent("agent1", "Alice", "Revised opinion...", null, null)); - // Synthesis is channel-level (null threadTs), not threaded - verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Synthesis")); + // Revision threads under Alice's own message + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq("ts-alice"), + contains("revised")); + } + + // ─── Synthesis (header + thread pattern) ─── + + @Test + void expandedMode_synthesis_postsHeaderAndThread() { + initExpanded(); + when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Synthesis"))) + .thenReturn("ts-synth"); + + listener.onSynthesisStart(new GroupConversationEventSink.SynthesisStartEvent("mod1")); + listener.onSpeakerComplete(speakerEvent("mod1", "Moderator", "The panel agrees...", null, null)); + + // Header at channel level + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Panel Synthesis")); + // Full content in thread + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq("ts-synth"), + eq("The panel agrees...")); } @Test - void compactMode_synthesis_postedInThread() { - initCompactMode(); - listener.onSynthesisStart(new GroupConversationEventSink.SynthesisStartEvent("moderator1")); + void synthesis_headerContainsModerator() { + initExpanded(); + when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), any())) + .thenReturn("ts-synth"); - listener.onSpeakerComplete(speakerEvent("moderator1", "Moderator", "Summary...", null, null)); + listener.onSynthesisStart(new GroupConversationEventSink.SynthesisStartEvent("mod1")); + listener.onSpeakerComplete(speakerEvent("mod1", "Moderator", "The panel agrees...", null, null)); - // In compact mode, synthesis stays in the thread - verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), contains("Synthesis")); + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), + contains("Moderator")); } // ─── Context Tracking ─── @Test void agentContext_trackedForFollowUp() { - initExpandedMode(); + initExpanded(); when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), any())) .thenReturn("ts1"); @@ -194,7 +226,7 @@ void agentContext_trackedForFollowUp() { @Test void agentContext_feedbackAccumulated() { - initExpandedMode(); + initExpanded(); when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), any())) .thenReturn("ts1"); @@ -210,7 +242,7 @@ void agentContext_feedbackAccumulated() { @Test void getAgentIdForMessageTs_returnsCorrectAgent() { - initExpandedMode(); + initExpanded(); when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Alice"))) .thenReturn("ts-alice"); when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Bob"))) @@ -228,29 +260,30 @@ void getAgentIdForMessageTs_returnsCorrectAgent() { @Test void blankResponse_notPosted() { - initCompactMode(); + initExpanded(); listener.onSpeakerComplete(speakerEvent("agent1", "Alice", " ", null, null)); listener.onSpeakerComplete(speakerEvent("agent1", "Alice", null, null, null)); - // No additional postMessage calls (only the onGroupStart one) + // No additional postMessage calls beyond the onGroupStart one verify(slackApi, times(1)).postMessage(any(), any(), any(), any()); } @Test void onGroupError_postsErrorMessage() { - initCompactMode(); + initExpanded(); listener.onGroupError(new GroupConversationEventSink.GroupErrorEvent("timeout")); - verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), contains("error")); + // Error posted at channel level in expanded mode + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("error")); } // ─── Completion Latch ─── @Test void awaitCompletion_returnsTrueAfterGroupComplete() { - initCompactMode(); + initExpanded(); listener.onGroupComplete(new GroupConversationEventSink.GroupCompleteEvent( ai.labs.eddi.configs.groups.model.GroupConversation.GroupConversationState.COMPLETED, null)); @@ -259,7 +292,7 @@ void awaitCompletion_returnsTrueAfterGroupComplete() { @Test void awaitCompletion_returnsTrueAfterGroupError() { - initCompactMode(); + initExpanded(); listener.onGroupError(new GroupConversationEventSink.GroupErrorEvent("fail")); assertTrue(listener.awaitCompletion(1, TimeUnit.SECONDS)); @@ -267,9 +300,7 @@ void awaitCompletion_returnsTrueAfterGroupError() { @Test void awaitCompletion_returnsFalseOnTimeout() { - initCompactMode(); - // Never call onGroupComplete/onGroupError - + initExpanded(); assertFalse(listener.awaitCompletion(50, TimeUnit.MILLISECONDS)); } @@ -277,19 +308,23 @@ void awaitCompletion_returnsFalseOnTimeout() { @Test void onGroupComplete_postsSynthesisFallback_whenNotPostedDuringSpeakerComplete() { - initCompactMode(); - // No onSynthesisStart/onSpeakerComplete, synthesis comes only in - // onGroupComplete + initExpanded(); + when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Synthesis"))) + .thenReturn("ts-synth"); + listener.onGroupComplete(new GroupConversationEventSink.GroupCompleteEvent( ai.labs.eddi.configs.groups.model.GroupConversation.GroupConversationState.COMPLETED, "Final synthesis answer")); - verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), contains("Synthesis")); + verify(slackApi).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Synthesis")); } @Test void onGroupComplete_doesNotDuplicateSynthesis_whenAlreadyPosted() { - initCompactMode(); + initExpanded(); + when(slackApi.postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), contains("Synthesis"))) + .thenReturn("ts-synth"); + listener.onSynthesisStart(new GroupConversationEventSink.SynthesisStartEvent("mod1")); listener.onSpeakerComplete(speakerEvent("mod1", "Moderator", "Synthesis via speaker", null, null)); @@ -298,20 +333,23 @@ void onGroupComplete_doesNotDuplicateSynthesis_whenAlreadyPosted() { ai.labs.eddi.configs.groups.model.GroupConversation.GroupConversationState.COMPLETED, "Duplicate synthesis")); - // Only one synthesis message posted (the one from onSpeakerComplete) - verify(slackApi, times(1)).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), eq(USER_THREAD), contains("Synthesis")); + // Only one synthesis header posted + verify(slackApi, times(1)).postMessage(eq(AUTH_TOKEN), eq(CHANNEL), isNull(), + contains("Synthesis")); } // ─── Helpers ─── - private void initCompactMode() { - listener.onGroupStart(new GroupConversationEventSink.GroupStartEvent( - "gc1", "g1", "Test?", "ROUND_TABLE", 2, List.of("a1", "a2"))); + private void initExpanded() { + listener.onGroupStart(groupStart("PEER_REVIEW", 3)); } - private void initExpandedMode() { - listener.onGroupStart(new GroupConversationEventSink.GroupStartEvent( - "gc1", "g1", "Test?", "PEER_REVIEW", 3, List.of("a1", "a2"))); + private GroupConversationEventSink.GroupStartEvent groupStart(String style, int memberCount) { + List ids = new java.util.ArrayList<>(); + for (int i = 0; i < memberCount; i++) + ids.add("a" + i); + return new GroupConversationEventSink.GroupStartEvent( + "gc1", "g1", "Test?", style, memberCount, ids); } private GroupConversationEventSink.SpeakerCompleteEvent speakerEvent( diff --git a/src/test/java/ai/labs/eddi/integrations/slack/SlackWebApiClientTest.java b/src/test/java/ai/labs/eddi/integrations/slack/SlackWebApiClientTest.java index a5d6d39ef..18edcf194 100644 --- a/src/test/java/ai/labs/eddi/integrations/slack/SlackWebApiClientTest.java +++ b/src/test/java/ai/labs/eddi/integrations/slack/SlackWebApiClientTest.java @@ -14,8 +14,9 @@ * Tests for {@link SlackWebApiClient}. *

    * These tests verify the constructor, exception contract for retryable errors, - * and graceful handling of non-retryable API responses. Full HTTP integration - * tests (using WireMock) are in the integration test suite. + * graceful handling of non-retryable API responses, and the Markdown→mrkdwn + * converter. Full HTTP integration tests (using WireMock) are in the + * integration test suite. */ class SlackWebApiClientTest { @@ -75,4 +76,139 @@ void postMessage_longMessage_returnsNull() { String result = client.postMessage("Bearer xoxb-invalid", "C0123", null, longText); assertNull(result); } + + // ─── convertMarkdownToSlackMrkdwn ─── + + @Test + void mrkdwn_null_returnsNull() { + assertNull(SlackWebApiClient.convertMarkdownToSlackMrkdwn(null)); + } + + @Test + void mrkdwn_empty_returnsEmpty() { + assertEquals("", SlackWebApiClient.convertMarkdownToSlackMrkdwn("")); + } + + @Test + void mrkdwn_bold_converts() { + assertEquals("This is *bold* text", + SlackWebApiClient.convertMarkdownToSlackMrkdwn("This is **bold** text")); + } + + @Test + void mrkdwn_multipleBold_allConverted() { + assertEquals("*first* and *second*", + SlackWebApiClient.convertMarkdownToSlackMrkdwn("**first** and **second**")); + } + + @Test + void mrkdwn_heading_h1_convertsToBold() { + assertEquals("*My Heading*", + SlackWebApiClient.convertMarkdownToSlackMrkdwn("# My Heading")); + } + + @Test + void mrkdwn_heading_h3_convertsToBold() { + assertEquals("*Sub Section*", + SlackWebApiClient.convertMarkdownToSlackMrkdwn("### Sub Section")); + } + + @Test + void mrkdwn_strikethrough_converts() { + assertEquals("This is ~deleted~ text", + SlackWebApiClient.convertMarkdownToSlackMrkdwn("This is ~~deleted~~ text")); + } + + @Test + void mrkdwn_horizontalRule_convertsToUnicodeLine() { + String result = SlackWebApiClient.convertMarkdownToSlackMrkdwn("---"); + assertTrue(result.contains("───")); + } + + @Test + void mrkdwn_horizontalRule_asterisks() { + String result = SlackWebApiClient.convertMarkdownToSlackMrkdwn("***"); + assertTrue(result.contains("───")); + } + + @Test + void mrkdwn_codeBlock_preserved() { + String input = "```java\nSystem.out.println(\"hello\");\n```"; + String result = SlackWebApiClient.convertMarkdownToSlackMrkdwn(input); + // Code blocks should not have their content converted + assertTrue(result.contains("System.out.println(\"hello\");")); + assertTrue(result.contains("```java")); + } + + @Test + void mrkdwn_codeBlock_boldNotConverted() { + String input = "```\n**not bold inside code**\n```"; + String result = SlackWebApiClient.convertMarkdownToSlackMrkdwn(input); + // Bold inside code should NOT be converted + assertTrue(result.contains("**not bold inside code**")); + } + + @Test + void mrkdwn_table_wrappedInCodeBlock() { + String input = "| Col A | Col B |\n|---|---|\n| val1 | val2 |"; + String result = SlackWebApiClient.convertMarkdownToSlackMrkdwn(input); + // Table should be wrapped in ``` for monospace display + assertTrue(result.startsWith("```")); + assertTrue(result.contains("val1")); + // Separator row should be removed + assertFalse(result.contains("|---|")); + } + + @Test + void mrkdwn_table_boldInTableRemoved() { + String input = "| **Header** | Value |\n|---|---|\n| data | more |"; + String result = SlackWebApiClient.convertMarkdownToSlackMrkdwn(input); + // Bold markers should be stripped inside tables + assertTrue(result.contains("Header") && !result.contains("**Header**")); + } + + @Test + void mrkdwn_mixedContent_allConverted() { + String input = "# Title\n\nSome **bold** text with ~~strike~~.\n\n---\n\nA paragraph."; + String result = SlackWebApiClient.convertMarkdownToSlackMrkdwn(input); + assertTrue(result.contains("*Title*")); + assertTrue(result.contains("*bold*")); + assertTrue(result.contains("~strike~")); + assertTrue(result.contains("───")); + assertTrue(result.contains("A paragraph.")); + } + + @Test + void mrkdwn_plainText_unchanged() { + assertEquals("Just a normal sentence.", + SlackWebApiClient.convertMarkdownToSlackMrkdwn("Just a normal sentence.")); + } + + @Test + void mrkdwn_inlineCode_preserved() { + // Inline code should pass through unchanged + assertEquals("Use `git status` to check", + SlackWebApiClient.convertMarkdownToSlackMrkdwn("Use `git status` to check")); + } + + @Test + void mrkdwn_bulletList_preserved() { + String input = "- item 1\n- item 2"; + String result = SlackWebApiClient.convertMarkdownToSlackMrkdwn(input); + assertTrue(result.contains("- item 1")); + assertTrue(result.contains("- item 2")); + } + + @Test + void mrkdwn_blockquote_preserved() { + String input = "> This is a quote"; + assertEquals("> This is a quote", + SlackWebApiClient.convertMarkdownToSlackMrkdwn(input)); + } + + @Test + void mrkdwn_emoji_preserved() { + assertEquals("🟢 Done ✅", + SlackWebApiClient.convertMarkdownToSlackMrkdwn("🟢 Done ✅")); + } } From bf7a94ce7f2915fbfb78065beb9211d47fc67bed Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 17 May 2026 22:02:19 -0400 Subject: [PATCH 34/35] fix(llm): update LlmTaskTest template counts for TEMPLATE_SKIP_PARAMS apiKey is now in TEMPLATE_SKIP_PARAMS and no longer processed by the template engine. Reduce expected processTemplate call counts by 1. --- src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskTest.java b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskTest.java index 35f7cd5f3..41fab8baa 100644 --- a/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskTest.java +++ b/src/test/java/ai/labs/eddi/modules/llm/impl/LlmTaskTest.java @@ -129,13 +129,13 @@ toolResponseTruncator, mock(ai.labs.eddi.engine.tenancy.TenantQuotaService.class static Stream provideParameters() { return Stream.of( Arguments.of(Map.of("systemMessage", "Act as a real estate agent", "logSizeLimit", "10", "apiKey", "", "addToOutput", "true"), - List.of(new TextOutputItem(TEST_MESSAGE_FROM_LLM, 0)), 4, // times for templatingEngine.processTemplate + List.of(new TextOutputItem(TEST_MESSAGE_FROM_LLM, 0)), 3, // times for templatingEngine.processTemplate (apiKey skipped) 2, // times for currentStep.storeData (audit writes guarded by // collector) 1 // times for currentStep.addConversationOutputList ), Arguments.of(Map.of("systemMessage", "Act as a real estate agent", "logSizeLimit", "10", "apiKey", ""), - List.of(new TextOutputItem(TEST_MESSAGE_FROM_LLM, 0)), 3, // times for templatingEngine.processTemplate + List.of(new TextOutputItem(TEST_MESSAGE_FROM_LLM, 0)), 2, // times for templatingEngine.processTemplate (apiKey skipped) 1, // times for currentStep.storeData (audit writes guarded by // collector) 0 // times for currentStep.addConversationOutputList From 86b06940dd5e4da4644842bfebcf026b51cc0b25 Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Sun, 17 May 2026 22:11:45 -0400 Subject: [PATCH 35/35] fix(slack): close table fence before real code block in mrkdwn converter When a markdown table was immediately followed by a fenced code block, the table wrapper ` was left unclosed, producing malformed mrkdwn. Now emit the closing fence before toggling into the real code block. Added test: mrkdwn_tableFollowedByCodeBlock_closesTableFence Co-authored-by: Copilot --- .../eddi/integrations/slack/SlackWebApiClient.java | 5 +++-- .../integrations/slack/SlackWebApiClientTest.java | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/main/java/ai/labs/eddi/integrations/slack/SlackWebApiClient.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackWebApiClient.java index f007047a4..cf48233d4 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackWebApiClient.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackWebApiClient.java @@ -168,11 +168,12 @@ static String convertMarkdownToSlackMrkdwn(String text) { // Toggle code block state — preserve code blocks untouched if (line.trim().startsWith("```")) { - inCodeBlock = !inCodeBlock; if (inTable) { - // Close any open table-as-code-block before the actual code block + // Close the table-as-code-block wrapper before the real code block + result.append("```\n"); inTable = false; } + inCodeBlock = !inCodeBlock; result.append(line).append("\n"); continue; } diff --git a/src/test/java/ai/labs/eddi/integrations/slack/SlackWebApiClientTest.java b/src/test/java/ai/labs/eddi/integrations/slack/SlackWebApiClientTest.java index 18edcf194..a1bc8a407 100644 --- a/src/test/java/ai/labs/eddi/integrations/slack/SlackWebApiClientTest.java +++ b/src/test/java/ai/labs/eddi/integrations/slack/SlackWebApiClientTest.java @@ -211,4 +211,16 @@ void mrkdwn_emoji_preserved() { assertEquals("🟢 Done ✅", SlackWebApiClient.convertMarkdownToSlackMrkdwn("🟢 Done ✅")); } + + @Test + void mrkdwn_tableFollowedByCodeBlock_closesTableFence() { + String input = "| A | B |\n|---|---|\n| 1 | 2 |\n```python\nprint('hi')\n```"; + String result = SlackWebApiClient.convertMarkdownToSlackMrkdwn(input); + // Table should be wrapped in its own ``` block, closed before the real code + // block + // Count ``` occurrences — should be even (balanced) + long fenceCount = result.lines().filter(l -> l.trim().startsWith("```")).count(); + assertEquals(0, fenceCount % 2, "Fences must be balanced, got " + fenceCount + " in:\n" + result); + assertTrue(result.contains("print('hi')"), "Code block content should be preserved"); + } }