diff --git a/docs/changelog.md b/docs/changelog.md index 9c3f72351..32fd1784c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -158,6 +158,303 @@ Each entry follows this format: - **Decision** — Key design decisions and their reasoning - **Files** — Links to modified files + + +## 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. + +- **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`) + +**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`) + +**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`) + +### 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`) + +### 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`) + +**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`) + +**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`) + +**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`) + +**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). + +--- + ## 🔍 DreamService PR Review Remediation — Pass 2 (2026-05-16) **Repo:** EDDI (`feature/dream-summarization`) @@ -271,6 +568,8 @@ Each entry follows this format: - `./mvnw compile` → BUILD SUCCESS - `./mvnw test -Dtest=DreamServiceTest` → 37 tests, 0 failures, 0 errors +--- + ## 🔧 PR Review Remediation — 8 Findings Resolved (2026-05-14) @@ -1123,6 +1422,7 @@ Created `LogSanitizer.java` in `ai.labs.eddi.utils` — replaces `\r`, `\n`, `\t --- + ## 🔒 OpenSSF Scorecard: Fuzzing + SLSA Provenance + Signed Releases (2026-04-23) **Repo:** EDDI (`chore/scorecard-improvements`) @@ -1714,6 +2014,7 @@ Renamed 20 Testcontainers-based datastore tests from `*IT.java` → `*Test.java` - `src/test/java/ai/labs/eddi/configs/migration/V6QuteMigrationTest.java` — new - `src/test/java/ai/labs/eddi/engine/triggermanagement/model/UserConversationTest.java` — new + --- ## PR Review Fixes — Quota Ordering, Log Injection, Doc Hygiene (2026-04-17) 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 5ba26ff06..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 @@ -28,22 +31,11 @@ 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: ```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"}' @@ -53,7 +45,55 @@ 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 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: @@ -73,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) + +For the bot to accept DMs, you must enable the Messages Tab: + +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 6. If no agent is configured yet, verification fails and Slack rejects the URL. +> ⚠️ **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** --- @@ -96,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 @@ -123,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) @@ -153,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? @@ -161,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) -Trigger with the `group:` prefix: +Send a message directly to the bot — no @mention needed: ``` -@EDDI group: Should we adopt microservices for the payment system? +Hello, what can you do? ``` -All configured agents in the group participate in a live panel discussion. +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. -#### UX Modes +> **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. -The UX mode is chosen automatically based on the discussion style: +### Trigger Keywords -| 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 | +Use colon-delimited trigger keywords to route to specific targets: -**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): ... +@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 ``` -**Expanded mode** gives each agent a channel-level post. Peer feedback threads under the target agent's post: +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 + +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. + +#### UX Pattern: Header + Thread + +All discussion styles use the same UX pattern — **header at channel level, full content in thread**: + +``` +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] +``` + +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] ``` @@ -220,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 @@ -242,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 @@ -262,15 +362,48 @@ When running EDDI as a multi-instance cluster behind a load balancer: ## Configuration Reference -### Server-Level +### ChannelIntegrationConfiguration (Recommended) -| Property | Default | Description | -|----------|---------|-------------| -| `eddi.slack.enabled` | `false` | Master toggle — infrastructure-level kill switch | - -### Per-Agent ChannelConnector Config +```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"] + } + ] +} +``` -Configure on each agent's `channels[]` array: +| 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 { @@ -284,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 @@ -340,15 +466,23 @@ 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 | +| 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 | @@ -356,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 @@ -401,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 | @@ -465,4 +548,5 @@ 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. | +| **Convert formatting at the egress point** | Markdown→mrkdwn conversion in the API client ensures consistent rendering across all code paths. | diff --git a/planning/conversation-cancel-plan.md b/planning/conversation-cancel-plan.md new file mode 100644 index 000000000..60fa6dc8c --- /dev/null +++ b/planning/conversation-cancel-plan.md @@ -0,0 +1,374 @@ +# Conversation Cancellation & Lifecycle Control + +> **Scope**: Cancel/stop for group discussions and regular conversations. +> **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. + +--- + +## 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**. + +### 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 — best-effort attempt to interrupt the current in-flight call (see §3.4) */ + CANCEL_IMMEDIATE, +} +``` + +> **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, idempotent. */ + 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; + } +} +``` + +### 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 +- If token not found (conversation already finished), return gracefully + +--- + +## 3. Safe Points + +### 3.1 Group Discussions — Graceful Cancel + +The execution loop has clear phase/speaker boundaries: + +``` +executeDiscussion() + for each phase: ← ✅ CHECK: between phases + for each repeat: + for each speaker (sequential): ← ✅ CHECK: before each speaker turn + executeAgentTurn() + for each speaker (parallel): + 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); +}); +``` + +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: + +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._ + +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. + +**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.5 Regular Conversations + +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 **between lifecycle tasks** (between parser, rules, LLM, output). If cancelled, throw `ConversationStopException`. + +``` +executeLifecycle() + for each task: ← ✅ Safe point: between lifecycle tasks + if (memory.isCancelled()) throw new ConversationStopException(); + task.executeTask(memory); +``` + +**Prerequisite**: `ConversationService` must track active `IConversationMemory` instances so external callers can find and cancel them: + +```java +// In ConversationService +private final ConcurrentMap activeMemories = new ConcurrentHashMap<>(); + +public void cancelConversation(String conversationId) { + var memory = activeMemories.get(conversationId); + if (memory != null) memory.setCancelled(true); +} +``` + +This reuses the existing `ConversationStopException` handling in `Conversation.executeConversationStep()` (line ~295). + +--- + +## 4. API Design + +### 4.1 Group Discussion Cancel + +``` +POST /groups/{groupId}/conversations/{groupConversationId}/cancel +Content-Type: application/json + +{ + "mode": "GRACEFUL" // or "IMMEDIATE" +} +``` + +- **`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. + +**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` + +### 4.2 Regular Conversation Cancel + +``` +POST /agents/{agentId}/conversations/{conversationId}/cancel +``` + +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) + +### 4.3 SSE Auto-Cancel + +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 — wrap every sendEvent call: +private boolean sendOrCancel(SseEventSink eventSink, Sse sse, String eventName, String data, String convId) { + if (eventSink.isClosed()) { + groupConversationService.cancelDiscussion(convId, ControlSignal.CANCEL_GRACEFUL); + return true; // cancelled + } + 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 + +### Group Conversation + +``` +CREATED → IN_PROGRESS → SYNTHESIZING → COMPLETED + ↘ CANCELLED (via user cancel or SSE disconnect) + ↘ FAILED (via error) +``` + +### Regular Conversation + +``` +READY → IN_PROGRESS → READY (completed turn) + ↘ EXECUTION_INTERRUPTED (via cancel — state already exists) + ↘ ERROR +``` + +--- + +## 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: + +| 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 | + +### What cancel provides as HITL groundwork + +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 + +### What HITL still requires (Phase 9b) + +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 + +**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: Group Discussion Cancel (EDDI repo) — Core feature + +| # | File | Change | +|---|------|--------| +| 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 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 definition | +| A7 | `RestGroupConversation.java` | Implement cancel endpoint + SSE auto-cancel via `sendOrCancel()` wrapper | + +### 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 | `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 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: `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` or just `200 OK`? + - *Recommendation*: Return the saved `GroupConversation` — the client can display the partial transcript. + +2. **Metrics**: Should cancelled discussions count as failures? + - *Recommendation*: Separate counter `eddi_group_discussion_cancelled_count`. + +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. **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)**: 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. diff --git a/pom.xml b/pom.xml index 3798f96af..9ea2f9af9 100644 --- a/pom.xml +++ b/pom.xml @@ -556,6 +556,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/main/java/ai/labs/eddi/configs/agents/model/AgentConfiguration.java b/src/main/java/ai/labs/eddi/configs/agents/model/AgentConfiguration.java index 22d1733f5..37581c3bf 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 @@ -19,6 +19,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<>(); /** @@ -78,6 +86,11 @@ public class AgentConfiguration { */ private SessionManagement sessionManagement; + /** + * @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<>(); @@ -107,10 +120,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/channels/IChannelIntegrationStore.java b/src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java new file mode 100644 index 000000000..f95e3c53d --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/IChannelIntegrationStore.java @@ -0,0 +1,17 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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..4d8471f5b --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/IRestChannelIntegrationStore.java @@ -0,0 +1,86 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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..3fc5dc49e --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelIntegrationConfiguration.java @@ -0,0 +1,109 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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}: + *

    + *
  • slack: {@code channelId}, {@code botToken}, + * {@code signingSecret}
  • + *
  • teams: {@code channelId}, {@code appId}, {@code appPassword}, + * {@code serviceUrl}
  • + *
  • discord: {@code guildId}, {@code channelId}, {@code botToken}, + * {@code publicKey}
  • + *
+ * Secret values should use vault references: {@code ${vault:key-name}}. + */ + public Map getPlatformConfig() { + return new HashMap<>(platformConfig); + } + + public void setPlatformConfig(Map platformConfig) { + this.platformConfig = platformConfig == null ? new HashMap<>() : new HashMap<>(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 new ArrayList<>(targets); + } + + public void setTargets(List targets) { + this.targets = targets == null ? new ArrayList<>() : new ArrayList<>(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..dc51894a0 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ChannelTarget.java @@ -0,0 +1,114 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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 != null ? new ArrayList<>(triggers) : null; + } + + 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..d1d3c5bb8 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/model/ObserveConfig.java @@ -0,0 +1,96 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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..71cb7478c --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/mongo/ChannelIntegrationStore.java @@ -0,0 +1,35 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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..a9c5fca9e --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStore.java @@ -0,0 +1,349 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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.Locale; +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"); + + /** + * 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; + + @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); + validateUniqueChannelId(channelConfiguration, id); + Response response = restVersionInfo.update(id, version, channelConfiguration); + syncDescriptor(id, channelConfiguration); + return response; + } + + @Override + public Response createChannel(ChannelIntegrationConfiguration channelConfiguration) { + validateConfiguration(channelConfiguration); + validateUniqueChannelId(channelConfiguration, null); + 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); + // Clear channelId so the duplicate doesn't collide in the router's + // integrationMap (each channelType:channelId must be unique) + if (config.getPlatformConfig() != null) { + var platformConfig = config.getPlatformConfig(); + platformConfig.remove("channelId"); + config.setPlatformConfig(platformConfig); + } + validateConfiguration(config); + 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 ──────────────────────────────────────────────────────────── + + // Visible for testing + 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(Locale.ROOT))) { + 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 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."); + } + // 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."); + } + // 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()) { + throw new BadRequestException( + "Target '" + target.getName() + + "' contains a null or blank trigger keyword."); + } + 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 + + "'. Each trigger must be unique across all targets."); + } + } + } + } + } + + // ─── 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 ─────────────────────────────────────────────────────── + + /** + * 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(); + } +} 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..86f8fc5d1 --- /dev/null +++ b/src/main/java/ai/labs/eddi/configs/migration/ChannelConnectorMigration.java @@ -0,0 +1,324 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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.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 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; + +/** + * 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"; + + /** + * 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; + private final IDocumentDescriptorStore descriptorStore; + private final IMigrationLogStore migrationLogStore; + + @Inject + public ChannelConnectorMigration(IDeploymentStore deploymentStore, + IAgentStore agentStore, + IChannelIntegrationStore channelStore, + IDocumentDescriptorStore descriptorStore, + IMigrationLogStore migrationLogStore) { + this.deploymentStore = deploymentStore; + this.agentStore = agentStore; + this.channelStore = channelStore; + this.descriptorStore = descriptorStore; + 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[] result = migrateConnectors(); // [created, failed, skipped] + int created = result[0]; + int failed = result[1]; + 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) — " + + "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 + } + + 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.read(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(Locale.ROOT); + String groupKey = channelType + ":" + channelId; + channelGroups.computeIfAbsent(groupKey, k -> new ArrayList<>()) + .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()); + } + } + } catch (Exception e) { + LOGGER.warn("Failed to read deployment infos for channel migration", e); + 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)); + + // 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(); + + var config = new ChannelIntegrationConfiguration(); + config.setName(channelType + " — " + channelId); + config.setChannelType(channelType); + // 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(); + + for (var ce : entries) { + var target = new ChannelTarget(); + String baseName = slugify(ce.agentName() != null ? ce.agentName() : ce.agentId()); + String targetName = baseName; + 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); + } + + // 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); + } + + 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) { + failed++; + LOGGER.warnf(" Failed to create config for %s:%s — %s", channelType, channelId, e.getMessage()); + } + } + + 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. + */ + 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) { + 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; + } + + /** + * 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/configs/migration/V6QuteMigration.java b/src/main/java/ai/labs/eddi/configs/migration/V6QuteMigration.java index a3f77a383..5585c483b 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/V6QuteMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/V6QuteMigration.java @@ -38,12 +38,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 f21471322..6a01d9dc5 100644 --- a/src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java +++ b/src/main/java/ai/labs/eddi/configs/migration/V6RenameMigration.java @@ -86,11 +86,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/internal/GroupConversationService.java b/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java index e819615b0..036fa2d5b 100644 --- a/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java +++ b/src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java @@ -32,6 +32,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; @@ -1003,12 +1004,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/engine/mcp/McpAdminTools.java b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java index 725444fb4..52ad70adc 100644 --- a/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java +++ b/src/main/java/ai/labs/eddi/engine/mcp/McpAdminTools.java @@ -101,7 +101,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); @@ -151,7 +151,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 { @@ -170,7 +170,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); @@ -942,7 +942,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"); @@ -1172,4 +1172,137 @@ 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 resource ID (required)") String resourceId, + @ToolArg(description = "Version number (default: latest)") Integer version) { + requireRole(identity, authEnabled, "eddi-admin"); + 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(resourceId); + var config = channelStore.readChannel(resourceId, ver); + + var result = new LinkedHashMap(); + 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 " + resourceId, 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( + "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, + "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 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 (resourceId == null || resourceId.isBlank()) + return errorJson("resourceId 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(resourceId, ver, channelConfig); + String location = response.getHeaderString("Location"); + int newVersion = extractVersionFromLocation(location); + + return resultJson("updated", Map.of( + "resourceId", resourceId, + "previousVersion", ver, + "newVersion", newVersion, + "status", response.getStatus())); + } catch (Exception 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 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 (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(resourceId, ver, isPermanent); + + return resultJson("deleted", Map.of( + "resourceId", resourceId, + "version", ver, + "permanent", isPermanent, + "status", response.getStatus())); + } catch (Exception e) { + LOGGER.error("MCP delete_channel_integration failed for " + resourceId, e); + return errorJson("Failed to delete channel integration: " + 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 56fbd910d..9205457b9 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 @@ -8,6 +8,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; @@ -62,6 +63,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; @@ -72,7 +74,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; @@ -83,6 +86,7 @@ public AgentDeploymentManagement(IDeploymentStore deploymentStore, IAgentFactory this.migrationManager = migrationManager; this.v6RenameMigration = v6RenameMigration; this.v6QuteMigration = v6QuteMigration; + this.channelConnectorMigration = channelConnectorMigration; this.runtime = runtime; this.maximumLifeTimeOfIdleConversationsInDays = maximumLifeTimeOfIdleConversationsInDays; } @@ -99,9 +103,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(); + // 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/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..b7c19dceb --- /dev/null +++ b/src/main/java/ai/labs/eddi/integrations/channels/ChannelTargetRouter.java @@ -0,0 +1,569 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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.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; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +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; + +/** + * 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}). + *

+ * 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 + * 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 → deep-copied ChannelIntegrationConfiguration with + * 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(); + + /** 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(); + + private volatile long lastRefreshTime = 0; + private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); + + /** + * 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, + ICacheFactory cacheFactory) { + this.channelStore = channelStore; + this.descriptorStore = descriptorStore; + this.agentAdmin = agentAdmin; + this.agentStore = agentStore; + this.secretResolver = secretResolver; + this.threadTargetLock = cacheFactory.getCache("channel-thread-locks", Duration.ofHours(24)); + } + + // ─── 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(); + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + + // 1. Try new-style ChannelIntegrationConfiguration + String key = normalizedType + ":" + 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(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()); + } + } + + 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. + * + * @return the locked target, or {@code null} if no lock exists for this thread + */ + public ResolvedTarget resolveThreadTarget(String channelType, String platformChannelId, + String 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 = normalizedType + ":" + platformChannelId; + ChannelIntegrationConfiguration integration = integrationMap.get(key); + + // 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 channelType, String platformChannelId, + String threadTs, ChannelTarget target) { + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + String lockKey = normalizedType + ":" + platformChannelId + ":" + threadTs; + threadTargetLock.put(lockKey, target); + } + + /** + * Get all signing secrets for a given platform type. Used by webhook signature + * verifiers. + */ + public Set getSigningSecrets(String channelType) { + refreshIfNeeded(); + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + if (CHANNEL_TYPE_SLACK.equals(normalizedType)) { + 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(); + String normalizedType = channelType != null ? channelType.toLowerCase(Locale.ROOT) : ""; + return Optional.ofNullable(integrationMap.get(normalizedType + ":" + 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 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"); + if (token != null && !token.isBlank()) { + return token; + } + } + // Fallback: legacy map (Slack only) + if (CHANNEL_TYPE_SLACK.equals(normalizedType)) { + 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). + */ + public boolean hasAnyChannels(String channelType) { + refreshIfNeeded(); + 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(normalizedType + ":")); + } + + // ─── 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(Locale.ROOT); + String remainder = trimmed.substring(colonIdx + 1).trim(); + + 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); + } + } + } + } + } + } + + // 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 || integration.getTargets() == 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.warn("Failed to refresh channel target router", e); + 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) { + + // 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()) { + var copy = deepCopyConfig(config); + resolvePlatformSecrets(copy); + 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(Locale.ROOT))) { + String ss = copy.getPlatformConfig().get("signingSecret"); + if (ss != null && !ss.isBlank()) { + newSigningSecrets.add(ss); + } + } + } + } + } catch (Exception e) { + LOGGER.debug("Skipping channel config", e); + } + } + } catch (Exception e) { + LOGGER.warn("Failed to load channel integration configs", e); + } + + // 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(e, "Skipping agent %s for legacy channel scan", + agentId); + } + } + } catch (Exception e) { + LOGGER.warn("Failed to scan legacy ChannelConnectors", e); + } + + // Atomic swap + integrationMap = Map.copyOf(newIntegrationMap); + legacyMap = Map.copyOf(newLegacyMap); + slackSigningSecrets = Set.copyOf(newSigningSecrets); + + 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 ${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 + * 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(); + 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()) { + 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.warn("Failed to resolve secret", e); + 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"); + 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/SlackChannelRouter.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackChannelRouter.java deleted file mode 100644 index aada5c0af..000000000 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackChannelRouter.java +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright EDDI contributors - * SPDX-License-Identifier: Apache-2.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.variables.GlobalVariableResolver; -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 ${vault:...}} 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 GlobalVariableResolver globalVariableResolver; - 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, - GlobalVariableResolver globalVariableResolver, SecretResolver secretResolver) { - this.agentAdmin = agentAdmin; - this.agentStore = agentStore; - this.globalVariableResolver = globalVariableResolver; - 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 - * (${vault:...}) 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 { - String resolved = globalVariableResolver.resolveValue(value); - return secretResolver.resolveValue(resolved); - } catch (Exception e) { - LOGGER.warnf("Failed to resolve %s for agent %s: %s", fieldName, agentId, e.getMessage()); - return 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 b01b94a45..0deddc2c5 100644 --- a/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java +++ b/src/main/java/ai/labs/eddi/integrations/slack/SlackEventHandler.java @@ -4,6 +4,7 @@ */ 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; @@ -14,12 +15,17 @@ 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.modules.output.model.OutputItem; import ai.labs.eddi.datastore.IResourceStore; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; 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.*; @@ -29,20 +35,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
  • @@ -62,11 +71,7 @@ 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; @@ -87,15 +92,13 @@ public class SlackEventHandler { private final ICache activeGroupListeners; @Inject - public SlackEventHandler(SlackIntegrationConfig config, - SlackChannelRouter channelRouter, + public SlackEventHandler(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; @@ -129,13 +132,9 @@ 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", eventId); + LOGGER.debugf("Duplicate Slack event %s — skipping", sanitize(eventId)); return; } eventDedup.put(eventId, Boolean.TRUE); @@ -144,7 +143,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"); @@ -152,7 +151,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 } @@ -162,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", 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"); @@ -180,78 +214,94 @@ 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, null); 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); + } + + // 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; } - // 3. Standard 1:1 agent conversation - handleAgentConversation(channelId, userId, text, threadTs); + // Lock target for this thread + if (threadTs != null) { + channelTargetRouter.lockThreadTarget("slack", channelId, threadTs, resolved.target()); + } + + // 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); + } } /** - * 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, String originalText, + String botToken) 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 threadKey = threadTs != null ? threadTs : "main"; + + // 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; - String agentId = agentIdOpt.get(); - String conversationId = getOrCreateConversation(agentId, userId, channelId, threadTs); - String response = sendAndWait(conversationId, text); - postMessageChunked(channelId, threadTs, response); + // 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, message); + postMessageChunked(channelId, threadTs, response, botToken); } // ─── 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(); + private void handleGroupDiscussion(ResolvedTarget resolved, String channelId, + String userId, String threadTs, String originalText, + String botToken) { + String groupId = resolved.target().getTargetId(); - // Resolve bot token for this channel - Optional credsOpt = channelRouter.resolveCredentials(channelId); - String botToken = credsOpt.map(SlackChannelRouter.SlackCredentials::botToken).orElse(""); - - 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.", sanitize(channelId)); return; } @@ -260,9 +310,10 @@ 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() != 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); @@ -275,7 +326,8 @@ private void handleGroupDiscussion(String channelId, String userId, } 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); } } @@ -324,15 +376,16 @@ 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); // 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); + postMessageChunked(channelId, threadTs, response, null); return true; } @@ -362,16 +415,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); @@ -382,8 +431,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, @@ -391,7 +439,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(); @@ -420,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(); @@ -429,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()) { @@ -442,15 +503,43 @@ 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._"; } /** * 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 == null || text.isEmpty()) + return; if (text.length() <= MAX_SLACK_MESSAGE_LENGTH) { - postMessage(channelId, threadTs, text); + postMessage(channelId, threadTs, text, botToken); return; } @@ -469,26 +558,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; } } /** - * Post a single message to Slack via the Web API, using the per-agent bot token - * resolved from the channel's ChannelConnector config. + * 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 for this channel - Optional credsOpt = channelRouter.resolveCredentials(channelId); - String botToken = credsOpt.map(SlackChannelRouter.SlackCredentials::botToken).orElse(""); + 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.isEmpty()) { - LOGGER.warnf("No bot token configured for Slack channel %s — cannot post message", channelId); + if (resolvedToken == null || resolvedToken.isEmpty()) { + LOGGER.warnf("No bot token configured for Slack channel %s — cannot post message", sanitize(channelId)); return; } - String auth = "Bearer " + botToken; + String auth = "Bearer " + resolvedToken; for (int attempt = 1; attempt <= SLACK_API_MAX_RETRIES; attempt++) { try { @@ -506,17 +601,54 @@ 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, + sanitize(channelId), sanitize(threadTs), text != null ? text.length() : 0, SLACK_API_MAX_RETRIES, e.getMessage()); } } } } + /** + * 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, String botToken) { + var integration = channelTargetRouter.getIntegration("slack", channelId); + if (integration.isEmpty()) { + postMessage(channelId, threadTs, + "👋 Hi! Send me a message and I'll respond.", botToken); + return; + } + + var config = integration.get(); + var sb = new StringBuilder(); + 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 = 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()) { + 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(), botToken); + } + /** * 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/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/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/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/SlackWebApiClient.java b/src/main/java/ai/labs/eddi/integrations/slack/SlackWebApiClient.java index 3582c8aa2..cf48233d4 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,102 @@ 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("```")) { + if (inTable) { + // 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; + } + + 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/integrations/slack/rest/RestSlackWebhook.java b/src/main/java/ai/labs/eddi/integrations/slack/rest/RestSlackWebhook.java index db35f74fc..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 @@ -4,9 +4,8 @@ */ 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; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,8 +32,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. @@ -50,20 +49,17 @@ public class RestSlackWebhook { private static final TypeReference> MAP_TYPE = new TypeReference<>() { }; - 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, + public RestSlackWebhook(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; @@ -87,16 +83,10 @@ 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 = channelRouter.getAllSigningSecrets(); + 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(); 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/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/configs/channels/model/ChannelModelTest.java b/src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java new file mode 100644 index 000000000..eb69de89c --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/channels/model/ChannelModelTest.java @@ -0,0 +1,148 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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 new file mode 100644 index 000000000..c177c4180 --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/channels/rest/RestChannelIntegrationStoreValidationTest.java @@ -0,0 +1,364 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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() { + // 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")); + + 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 + @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")); + } + + @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 ──────────────────────────────────────────────────────── + + @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)); + } + } + + // ─── 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/configs/migration/ChannelConnectorMigrationTest.java b/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java new file mode 100644 index 000000000..6770f28d9 --- /dev/null +++ b/src/test/java/ai/labs/eddi/configs/migration/ChannelConnectorMigrationTest.java @@ -0,0 +1,497 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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 IMigrationLogStore 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(IMigrationLogStore.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/configs/migration/V6QuteMigrationTest.java b/src/test/java/ai/labs/eddi/configs/migration/V6QuteMigrationTest.java index f43267e09..e0c194609 100644 --- a/src/test/java/ai/labs/eddi/configs/migration/V6QuteMigrationTest.java +++ b/src/test/java/ai/labs/eddi/configs/migration/V6QuteMigrationTest.java @@ -20,13 +20,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 336571213..e3c2392a1 100644 --- a/src/test/java/ai/labs/eddi/configs/migration/V6RenameMigrationTest.java +++ b/src/test/java/ai/labs/eddi/configs/migration/V6RenameMigrationTest.java @@ -26,7 +26,7 @@ class V6RenameMigrationTest { @Mock private MongoDatabase database; @Mock - private MigrationLogStore migrationLogStore; + private IMigrationLogStore migrationLogStore; private V6RenameMigration migration; diff --git a/src/test/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagementExtendedTest.java b/src/test/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagementExtendedTest.java index c7097d62a..a01442a12 100644 --- a/src/test/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagementExtendedTest.java +++ b/src/test/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagementExtendedTest.java @@ -10,6 +10,7 @@ import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; import ai.labs.eddi.configs.descriptors.model.DocumentDescriptor; import ai.labs.eddi.configs.migration.IMigrationManager; +import ai.labs.eddi.configs.migration.ChannelConnectorMigration; import ai.labs.eddi.configs.migration.V6QuteMigration; import ai.labs.eddi.configs.migration.V6RenameMigration; import ai.labs.eddi.datastore.IResourceStore; @@ -60,13 +61,14 @@ void setUp() { migrationManager = mock(IMigrationManager.class); var v6Rename = mock(V6RenameMigration.class); var v6Qute = mock(V6QuteMigration.class); + var channelMigration = mock(ChannelConnectorMigration.class); var runtime = mock(IRuntime.class); when(runtime.getScheduledExecutorService()).thenReturn(mock(ScheduledExecutorService.class)); management = new AgentDeploymentManagement( deploymentStore, agentFactory, agentStore, agentsReadiness, conversationMemoryStore, documentDescriptorStore, - migrationManager, v6Rename, v6Qute, runtime, 30); + migrationManager, v6Rename, v6Qute, channelMigration, runtime, 30); } // ─── manageAgentDeployments ───────────────────────────── diff --git a/src/test/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagementTest.java b/src/test/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagementTest.java index 1b550c05d..f3552a95b 100644 --- a/src/test/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagementTest.java +++ b/src/test/java/ai/labs/eddi/engine/runtime/internal/AgentDeploymentManagementTest.java @@ -9,6 +9,7 @@ import ai.labs.eddi.configs.deployment.model.DeploymentInfo; import ai.labs.eddi.configs.descriptors.IDocumentDescriptorStore; import ai.labs.eddi.configs.migration.IMigrationManager; +import ai.labs.eddi.configs.migration.ChannelConnectorMigration; import ai.labs.eddi.configs.migration.V6QuteMigration; import ai.labs.eddi.configs.migration.V6RenameMigration; import ai.labs.eddi.datastore.IResourceStore; @@ -40,6 +41,7 @@ class AgentDeploymentManagementTest { private IMigrationManager migrationManager; private V6RenameMigration v6RenameMigration; private V6QuteMigration v6QuteMigration; + private ChannelConnectorMigration channelConnectorMigration; private IRuntime runtime; private AgentDeploymentManagement management; @@ -54,6 +56,7 @@ void setUp() { migrationManager = mock(IMigrationManager.class); v6RenameMigration = mock(V6RenameMigration.class); v6QuteMigration = mock(V6QuteMigration.class); + channelConnectorMigration = mock(ChannelConnectorMigration.class); runtime = mock(IRuntime.class); var scheduler = mock(ScheduledExecutorService.class); @@ -63,7 +66,7 @@ void setUp() { deploymentStore, agentFactory, agentStore, agentsReadiness, conversationMemoryStore, documentDescriptorStore, migrationManager, v6RenameMigration, v6QuteMigration, - runtime, 30); + channelConnectorMigration, runtime, 30); } @Nested diff --git a/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java b/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java index fffd27f80..49982e29d 100644 --- a/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java +++ b/src/test/java/ai/labs/eddi/integration/ContainerBaseIT.java @@ -63,7 +63,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 be4d77833..53e09719a 100644 --- a/src/test/java/ai/labs/eddi/integration/PostgresIntegrationTestProfile.java +++ b/src/test/java/ai/labs/eddi/integration/PostgresIntegrationTestProfile.java @@ -30,8 +30,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 8fc5a38f5..fe74ff6f1 100644 --- a/src/test/java/ai/labs/eddi/integration/postgres/PostgresAgentUseCaseIT.java +++ b/src/test/java/ai/labs/eddi/integration/postgres/PostgresAgentUseCaseIT.java @@ -76,7 +76,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() { 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..f075aaf49 --- /dev/null +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterRefreshTest.java @@ -0,0 +1,884 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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()); + } + } + + // ─── 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 + @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 { + @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); + } + } +} 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..e52e52731 --- /dev/null +++ b/src/test/java/ai/labs/eddi/integrations/channels/ChannelTargetRouterTest.java @@ -0,0 +1,760 @@ +/* + * Copyright EDDI contributors + * SPDX-License-Identifier: Apache-2.0 + */ +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.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.Set; +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 + * 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() { + 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"); + 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, " ")); + } + + @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 ───────────────────────────────────────────────── + + @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("slack", "C07TEST", "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("slack", "C07TEST", "thread-1", arch); + router.lockThreadTarget("slack", "C07TEST", "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()); + } + + @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 ───── + + 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); + } + } +} 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 4edd9d0e5..000000000 --- a/src/test/java/ai/labs/eddi/integrations/slack/SlackChannelRouterTest.java +++ /dev/null @@ -1,359 +0,0 @@ -/* - * Copyright EDDI contributors - * SPDX-License-Identifier: Apache-2.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.configs.variables.GlobalVariableResolver; -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 GlobalVariableResolver globalVariableResolver; - private SlackChannelRouter router; - - @BeforeEach - void setUp() { - agentAdmin = mock(IRestAgentAdministration.class); - agentStore = mock(IRestAgentStore.class); - secretResolver = mock(SecretResolver.class); - globalVariableResolver = mock(GlobalVariableResolver.class); - - // By default, both resolvers pass through unchanged - when(secretResolver.resolveValue(anyString())).thenAnswer(inv -> inv.getArgument(0)); - when(globalVariableResolver.resolveValue(anyString())).thenAnswer(inv -> inv.getArgument(0)); - - router = new SlackChannelRouter(agentAdmin, agentStore, globalVariableResolver, 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("${vault:slack-token}")).thenReturn("xoxb-resolved"); - when(secretResolver.resolveValue("${vault:slack-secret}")).thenReturn("resolved-secret"); - - setupDeployedAgent("agent-1", 1, "C0123", - "${vault:slack-token}", "${vault: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("${vault: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", - "${vault: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("${vault:bad-secret-ref}")) - .thenThrow(new RuntimeException("Vault key not found")); - - setupDeployedAgent("agent-1", 1, "C0123", - "xoxb-good-token", "${vault: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); - } -} 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..a1bc8a407 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,151 @@ 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 ✅")); + } + + @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"); + } } 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 5b7cc8135..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 @@ -4,9 +4,8 @@ */ 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; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.ws.rs.core.Response; @@ -15,19 +14,19 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.util.Map; 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}. + * Unit tests for {@link RestSlackWebhook}. Covers signature verification, URL + * verification challenge, event dispatching, and disabled/error paths. */ class RestSlackWebhookTest { - private SlackIntegrationConfig config; - private SlackChannelRouter channelRouter; + private ChannelTargetRouter channelTargetRouter; private SlackSignatureVerifier signatureVerifier; private SlackEventHandler eventHandler; private ObjectMapper objectMapper; @@ -35,61 +34,48 @@ class RestSlackWebhookTest { @BeforeEach void setUp() { - config = mock(SlackIntegrationConfig.class); - channelRouter = mock(SlackChannelRouter.class); + channelTargetRouter = mock(ChannelTargetRouter.class); signatureVerifier = mock(SlackSignatureVerifier.class); eventHandler = mock(SlackEventHandler.class); objectMapper = new ObjectMapper(); - webhook = new RestSlackWebhook(config, channelRouter, signatureVerifier, eventHandler, objectMapper); - } - - @Nested - @DisplayName("When Slack integration is disabled") - class Disabled { - - @Test - @DisplayName("should return 404 when disabled") - void returns404WhenDisabled() { - when(config.enabled()).thenReturn(false); - Response response = webhook.handleEvents("{}", "sig", "ts"); - - assertEquals(404, response.getStatus()); - } + webhook = new RestSlackWebhook(channelTargetRouter, signatureVerifier, + eventHandler, objectMapper); } + // ─── Signature verification ─────────────────────────────────────────────── + @Nested @DisplayName("Signature verification") class SignatureVerification { @Test - @DisplayName("should return 403 when signature verification fails") + @DisplayName("returns 403 when signature verification fails") void returns403OnBadSignature() { - when(config.enabled()).thenReturn(true); - when(channelRouter.getAllSigningSecrets()).thenReturn(Set.of("secret1")); - when(signatureVerifier.verify("ts", "{}", "bad-sig", Set.of("secret1"))) + 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") + @DisplayName("URL verification challenge") class UrlVerification { @Test - @DisplayName("should echo challenge for url_verification type") - void echoesChallenge() throws Exception { - when(config.enabled()).thenReturn(true); - when(channelRouter.getAllSigningSecrets()).thenReturn(Set.of("secret1")); - when(signatureVerifier.verify(anyString(), anyString(), anyString(), any())) - .thenReturn(true); + @DisplayName("echoes challenge for url_verification type") + void echoesChallenge() { + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); - String body = objectMapper.writeValueAsString( - Map.of("type", "url_verification", "challenge", "abc123")); + String body = "{\"type\":\"url_verification\",\"challenge\":\"abc123\"}"; Response response = webhook.handleEvents(body, "sig", "ts"); @@ -99,15 +85,12 @@ void echoesChallenge() throws Exception { } @Test - @DisplayName("should handle null challenge gracefully") - void handlesNullChallenge() throws Exception { - when(config.enabled()).thenReturn(true); - when(channelRouter.getAllSigningSecrets()).thenReturn(Set.of("s1")); - when(signatureVerifier.verify(anyString(), anyString(), anyString(), any())) - .thenReturn(true); + @DisplayName("handles null challenge gracefully") + void handleNullChallenge() { + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); - String body = objectMapper.writeValueAsString( - Map.of("type", "url_verification")); + String body = "{\"type\":\"url_verification\"}"; Response response = webhook.handleEvents(body, "sig", "ts"); @@ -115,62 +98,47 @@ void handlesNullChallenge() throws Exception { } } + // ─── Event callback ─────────────────────────────────────────────────────── + @Nested - @DisplayName("Event callbacks") - class EventCallbacks { + @DisplayName("Event callback") + class EventCallback { @Test - @DisplayName("should delegate event_callback to handler and return 200") - void delegatesEventCallback() throws Exception { - when(config.enabled()).thenReturn(true); - when(channelRouter.getAllSigningSecrets()).thenReturn(Set.of("s1")); - when(signatureVerifier.verify(anyString(), anyString(), anyString(), any())) - .thenReturn(true); - - String body = objectMapper.writeValueAsString(Map.of( - "type", "event_callback", - "event_id", "ev-1", - "event", Map.of("type", "app_mention", "text", "hello"))); + @DisplayName("delegates event_callback to eventHandler") + void delegatesEventCallback() { + 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("ev-1"), any()); + verify(eventHandler).handleEventAsync(eq("evt-1"), any()); } @Test - @DisplayName("should handle event_callback with null event gracefully") - void handlesNullEvent() throws Exception { - when(config.enabled()).thenReturn(true); - when(channelRouter.getAllSigningSecrets()).thenReturn(Set.of("s1")); - when(signatureVerifier.verify(anyString(), anyString(), anyString(), any())) - .thenReturn(true); - - // event_callback without "event" key - String body = objectMapper.writeValueAsString(Map.of( - "type", "event_callback", - "event_id", "ev-1")); + @DisplayName("returns 200 even when event is null") + void nullEvent() { + 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); } - } - - @Nested - @DisplayName("Unknown event types") - class UnknownTypes { @Test - @DisplayName("should return 200 for unknown event types") - void returns200ForUnknown() throws Exception { - when(config.enabled()).thenReturn(true); - when(channelRouter.getAllSigningSecrets()).thenReturn(Set.of("s1")); - when(signatureVerifier.verify(anyString(), anyString(), anyString(), any())) - .thenReturn(true); + @DisplayName("unknown type returns 200 (Slack expects it)") + void unknownType() { + when(channelTargetRouter.getSigningSecrets("slack")).thenReturn(Set.of("secret")); + when(signatureVerifier.verify(any(), any(), any(), any())).thenReturn(true); - String body = objectMapper.writeValueAsString(Map.of("type", "unknown_type")); + String body = "{\"type\":\"something_else\"}"; Response response = webhook.handleEvents(body, "sig", "ts"); @@ -178,19 +146,19 @@ void returns200ForUnknown() throws Exception { } } + // ─── Malformed payload ──────────────────────────────────────────────────── + @Nested - @DisplayName("Error handling") - class ErrorHandling { + @DisplayName("Malformed payload") + class MalformedPayload { @Test - @DisplayName("should return 400 for invalid JSON") - void returns400ForBadJson() { - when(config.enabled()).thenReturn(true); - when(channelRouter.getAllSigningSecrets()).thenReturn(Set.of("s1")); - when(signatureVerifier.verify(anyString(), anyString(), anyString(), any())) - .thenReturn(true); - - Response response = webhook.handleEvents("not-json{{{", "sig", "ts"); + @DisplayName("returns 400 for invalid JSON") + void invalidJson() { + 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()); } 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