@open-mercato/channel-discord 0.6.8-develop.7090.1.4dbca7b350
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js +86 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js +39 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js +97 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js +65 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js +110 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js +103 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js +93 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js +52 -0
- package/dist/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js +46 -0
- package/dist/modules/channel_discord/__integration__/helpers/discordSignature.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js +16 -0
- package/dist/modules/channel_discord/__integration__/helpers/eventsQueue.js.map +7 -0
- package/dist/modules/channel_discord/__integration__/meta.js +10 -0
- package/dist/modules/channel_discord/__integration__/meta.js.map +7 -0
- package/dist/modules/channel_discord/acl.js +15 -0
- package/dist/modules/channel_discord/acl.js.map +7 -0
- package/dist/modules/channel_discord/ai-agents.js +97 -0
- package/dist/modules/channel_discord/ai-agents.js.map +7 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js +73 -0
- package/dist/modules/channel_discord/api/ai-auto-reply/channels/route.js.map +7 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +222 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +7 -0
- package/dist/modules/channel_discord/api/interactions/route.js +173 -0
- package/dist/modules/channel_discord/api/interactions/route.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js +196 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.js.map +7 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js +14 -0
- package/dist/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.js.map +7 -0
- package/dist/modules/channel_discord/cli.js +142 -0
- package/dist/modules/channel_discord/cli.js.map +7 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js +116 -0
- package/dist/modules/channel_discord/commands/ai-reply-proposal.js.map +7 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js +104 -0
- package/dist/modules/channel_discord/commands/update-ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/di.js +23 -0
- package/dist/modules/channel_discord/di.js.map +7 -0
- package/dist/modules/channel_discord/index.js +9 -0
- package/dist/modules/channel_discord/index.js.map +7 -0
- package/dist/modules/channel_discord/integration.js +96 -0
- package/dist/modules/channel_discord/integration.js.map +7 -0
- package/dist/modules/channel_discord/lib/adapter.js +248 -0
- package/dist/modules/channel_discord/lib/adapter.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js +45 -0
- package/dist/modules/channel_discord/lib/ai-agent-directory.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-features.js +9 -0
- package/dist/modules/channel_discord/lib/ai-features.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js +9 -0
- package/dist/modules/channel_discord/lib/ai-proposal-contract.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js +109 -0
- package/dist/modules/channel_discord/lib/ai-proposal.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-reply.js +96 -0
- package/dist/modules/channel_discord/lib/ai-reply.js.map +7 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js +70 -0
- package/dist/modules/channel_discord/lib/ai-service-principal.js.map +7 -0
- package/dist/modules/channel_discord/lib/capabilities.js +42 -0
- package/dist/modules/channel_discord/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-access.js +62 -0
- package/dist/modules/channel_discord/lib/channel-access.js.map +7 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js +103 -0
- package/dist/modules/channel_discord/lib/channel-state-store.js.map +7 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js +38 -0
- package/dist/modules/channel_discord/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/credentials.js +61 -0
- package/dist/modules/channel_discord/lib/credentials.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js +289 -0
- package/dist/modules/channel_discord/lib/discord-gateway-client.js.map +7 -0
- package/dist/modules/channel_discord/lib/discord-rest.js +203 -0
- package/dist/modules/channel_discord/lib/discord-rest.js.map +7 -0
- package/dist/modules/channel_discord/lib/failure-reason.js +30 -0
- package/dist/modules/channel_discord/lib/failure-reason.js.map +7 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js +42 -0
- package/dist/modules/channel_discord/lib/gateway-bridge.js.map +7 -0
- package/dist/modules/channel_discord/lib/health.js +37 -0
- package/dist/modules/channel_discord/lib/health.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js +164 -0
- package/dist/modules/channel_discord/lib/interactions-dispatch.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js +115 -0
- package/dist/modules/channel_discord/lib/interactions-handler.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js +17 -0
- package/dist/modules/channel_discord/lib/interactions-queue.js.map +7 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js +84 -0
- package/dist/modules/channel_discord/lib/interactions-verify.js.map +7 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js +59 -0
- package/dist/modules/channel_discord/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_discord/lib/preset.js +63 -0
- package/dist/modules/channel_discord/lib/preset.js.map +7 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js +12 -0
- package/dist/modules/channel_discord/lib/queue-strategy.js.map +7 -0
- package/dist/modules/channel_discord/lib/slash-commands.js +42 -0
- package/dist/modules/channel_discord/lib/slash-commands.js.map +7 -0
- package/dist/modules/channel_discord/message-types.js +64 -0
- package/dist/modules/channel_discord/message-types.js.map +7 -0
- package/dist/modules/channel_discord/setup.js +62 -0
- package/dist/modules/channel_discord/setup.js.map +7 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js +185 -0
- package/dist/modules/channel_discord/subscribers/ai-auto-reply.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js +79 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js +20 -0
- package/dist/modules/channel_discord/widgets/injection/ai-auto-reply/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js +220 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_discord/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_discord/widgets/injection-table.js +26 -0
- package/dist/modules/channel_discord/widgets/injection-table.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js +270 -0
- package/dist/modules/channel_discord/workers/discord-gateway.js.map +7 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js +113 -0
- package/dist/modules/channel_discord/workers/discord-interactions.js.map +7 -0
- package/jest.config.cjs +38 -0
- package/package.json +104 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-001.spec.ts +109 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-002.spec.ts +58 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-003.spec.ts +175 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-004.spec.ts +83 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-005.spec.ts +156 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-006.spec.ts +144 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-007.spec.ts +119 -0
- package/src/modules/channel_discord/__integration__/TC-CHANNEL-DISCORD-008.spec.ts +75 -0
- package/src/modules/channel_discord/__integration__/helpers/discordSignature.ts +77 -0
- package/src/modules/channel_discord/__integration__/helpers/eventsQueue.ts +26 -0
- package/src/modules/channel_discord/__integration__/meta.ts +6 -0
- package/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts +286 -0
- package/src/modules/channel_discord/acl.ts +19 -0
- package/src/modules/channel_discord/ai-agents.ts +157 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/__tests__/route.test.ts +175 -0
- package/src/modules/channel_discord/api/ai-auto-reply/channels/route.ts +99 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.test.ts +311 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +299 -0
- package/src/modules/channel_discord/api/interactions/route.ts +242 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.meta.ts +18 -0
- package/src/modules/channel_discord/backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx +288 -0
- package/src/modules/channel_discord/cli.ts +234 -0
- package/src/modules/channel_discord/commands/__tests__/ai-reply-proposal.test.ts +155 -0
- package/src/modules/channel_discord/commands/__tests__/update-ai-auto-reply.test.ts +185 -0
- package/src/modules/channel_discord/commands/ai-reply-proposal.ts +162 -0
- package/src/modules/channel_discord/commands/update-ai-auto-reply.ts +157 -0
- package/src/modules/channel_discord/di.ts +26 -0
- package/src/modules/channel_discord/i18n/de.json +57 -0
- package/src/modules/channel_discord/i18n/en.json +57 -0
- package/src/modules/channel_discord/i18n/es.json +57 -0
- package/src/modules/channel_discord/i18n/ko.json +57 -0
- package/src/modules/channel_discord/i18n/pl.json +57 -0
- package/src/modules/channel_discord/index.ts +6 -0
- package/src/modules/channel_discord/integration.ts +98 -0
- package/src/modules/channel_discord/lib/__tests__/adapter.test.ts +206 -0
- package/src/modules/channel_discord/lib/__tests__/ai-agent-directory.test.ts +61 -0
- package/src/modules/channel_discord/lib/__tests__/ai-reply.test.ts +115 -0
- package/src/modules/channel_discord/lib/__tests__/capabilities.test.ts +170 -0
- package/src/modules/channel_discord/lib/__tests__/channel-quarantine.test.ts +89 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.auto-reply-marker.test.ts +145 -0
- package/src/modules/channel_discord/lib/__tests__/channel-state-store.test.ts +78 -0
- package/src/modules/channel_discord/lib/__tests__/convert-outbound.test.ts +39 -0
- package/src/modules/channel_discord/lib/__tests__/discord-gateway-client.test.ts +243 -0
- package/src/modules/channel_discord/lib/__tests__/failure-reason.test.ts +68 -0
- package/src/modules/channel_discord/lib/__tests__/gateway-bridge.test.ts +62 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-dispatch.test.ts +223 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-handler.test.ts +469 -0
- package/src/modules/channel_discord/lib/__tests__/interactions-verify.test.ts +126 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.identity.test.ts +64 -0
- package/src/modules/channel_discord/lib/__tests__/normalize-inbound.test.ts +67 -0
- package/src/modules/channel_discord/lib/__tests__/preset.test.ts +110 -0
- package/src/modules/channel_discord/lib/__tests__/queue-strategy.test.ts +33 -0
- package/src/modules/channel_discord/lib/adapter.ts +359 -0
- package/src/modules/channel_discord/lib/ai-agent-directory.ts +124 -0
- package/src/modules/channel_discord/lib/ai-features.ts +23 -0
- package/src/modules/channel_discord/lib/ai-proposal-contract.ts +24 -0
- package/src/modules/channel_discord/lib/ai-proposal.ts +179 -0
- package/src/modules/channel_discord/lib/ai-reply.ts +179 -0
- package/src/modules/channel_discord/lib/ai-service-principal.ts +143 -0
- package/src/modules/channel_discord/lib/capabilities.ts +104 -0
- package/src/modules/channel_discord/lib/channel-access.ts +110 -0
- package/src/modules/channel_discord/lib/channel-state-store.ts +217 -0
- package/src/modules/channel_discord/lib/convert-outbound.ts +88 -0
- package/src/modules/channel_discord/lib/credentials.ts +88 -0
- package/src/modules/channel_discord/lib/discord-gateway-client.ts +432 -0
- package/src/modules/channel_discord/lib/discord-rest.ts +393 -0
- package/src/modules/channel_discord/lib/failure-reason.ts +49 -0
- package/src/modules/channel_discord/lib/gateway-bridge.ts +79 -0
- package/src/modules/channel_discord/lib/health.ts +55 -0
- package/src/modules/channel_discord/lib/interactions-dispatch.ts +288 -0
- package/src/modules/channel_discord/lib/interactions-handler.ts +267 -0
- package/src/modules/channel_discord/lib/interactions-queue.ts +52 -0
- package/src/modules/channel_discord/lib/interactions-verify.ts +174 -0
- package/src/modules/channel_discord/lib/normalize-inbound.ts +89 -0
- package/src/modules/channel_discord/lib/preset.ts +109 -0
- package/src/modules/channel_discord/lib/queue-strategy.ts +29 -0
- package/src/modules/channel_discord/lib/slash-commands.ts +57 -0
- package/src/modules/channel_discord/message-types.ts +75 -0
- package/src/modules/channel_discord/setup.ts +78 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.armed.test.ts +149 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts +133 -0
- package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.test.ts +273 -0
- package/src/modules/channel_discord/subscribers/ai-auto-reply.ts +325 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.client.tsx +143 -0
- package/src/modules/channel_discord/widgets/injection/ai-auto-reply/widget.ts +19 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.client.tsx +237 -0
- package/src/modules/channel_discord/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_discord/widgets/injection-table.ts +24 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.single-identify.test.ts +88 -0
- package/src/modules/channel_discord/workers/__tests__/discord-gateway.test.ts +179 -0
- package/src/modules/channel_discord/workers/__tests__/discord-interactions.test.ts +152 -0
- package/src/modules/channel_discord/workers/discord-gateway.ts +458 -0
- package/src/modules/channel_discord/workers/discord-interactions.ts +162 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Armed contract for the AI auto-reply subscriber (issue #4778).
|
|
6
|
+
*
|
|
7
|
+
* This test replaces the dormancy contract that shipped with #4391. That one
|
|
8
|
+
* asserted the inverse — that NO product surface could arm the subscriber —
|
|
9
|
+
* which was the honest statement while the feature was de-scoped. Now that the
|
|
10
|
+
* feature is real, the property worth pinning is the opposite one, and it is
|
|
11
|
+
* narrower: the two arming keys have exactly ONE writer, that writer is the
|
|
12
|
+
* settings command, and the operator surface in front of it is gated on
|
|
13
|
+
* `channel_discord.configure`.
|
|
14
|
+
*
|
|
15
|
+
* Structural assertions are worth having here because the properties they cover
|
|
16
|
+
* are invisible to a behavioural test: a second writer that skipped the command
|
|
17
|
+
* (and therefore the optimistic-lock check and the audit entry) would keep every
|
|
18
|
+
* unit test green, and so would a `features: []` regression in the subscriber —
|
|
19
|
+
* the exact defect the re-review of #4391 found.
|
|
20
|
+
*/
|
|
21
|
+
const ARMING_KEYS = ['aiAutoReplyEnabled', 'aiAgentId'] as const
|
|
22
|
+
|
|
23
|
+
/** The only file allowed to WRITE the arming keys. */
|
|
24
|
+
const WRITER = 'commands/update-ai-auto-reply.ts'
|
|
25
|
+
|
|
26
|
+
/** Files allowed to name the keys at all, and the role each plays. */
|
|
27
|
+
const KEY_NAMERS = [
|
|
28
|
+
WRITER,
|
|
29
|
+
'lib/ai-reply.ts', // reads the toggle + agent id for the subscriber
|
|
30
|
+
'lib/credentials.ts', // declares the channelState schema they live in
|
|
31
|
+
'lib/channel-state-store.ts', // carries them forward when the gateway writes resume state
|
|
32
|
+
'api/channels/[id]/ai-auto-reply/route.ts', // GET reads them for the settings form, PUT validates the payload before the command
|
|
33
|
+
'api/ai-auto-reply/channels/route.ts', // reads them for the integration panel's single-call listing
|
|
34
|
+
'backend/channel_discord/channels/[id]/ai-auto-reply/page.tsx', // the settings form itself
|
|
35
|
+
'widgets/injection/ai-auto-reply/widget.client.tsx', // shows the current state per channel
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Prose describes the very patterns these assertions forbid — the subscriber's
|
|
40
|
+
* own header explains why it never passes `features: []`. Matching against code
|
|
41
|
+
* only keeps a doc comment from failing the build it is documenting.
|
|
42
|
+
*/
|
|
43
|
+
function stripComments(source: string): string {
|
|
44
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function collectSourceFiles(root: string): string[] {
|
|
48
|
+
const found: string[] = []
|
|
49
|
+
const walk = (dir: string) => {
|
|
50
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
51
|
+
const absolute = path.join(dir, entry.name)
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (entry.name === '__tests__' || entry.name === '__integration__') continue
|
|
54
|
+
walk(absolute)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (/\.(ts|tsx)$/.test(entry.name)) found.push(absolute)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
walk(root)
|
|
61
|
+
return found
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('channel_discord ai-auto-reply — armed contract', () => {
|
|
65
|
+
const packageSrc = path.resolve(__dirname, '../../../..')
|
|
66
|
+
const moduleRoot = path.join(packageSrc, 'modules', 'channel_discord')
|
|
67
|
+
const files = collectSourceFiles(packageSrc)
|
|
68
|
+
const relative = (file: string) => path.relative(moduleRoot, file).split(path.sep).join('/')
|
|
69
|
+
const readSettingsRoute = () =>
|
|
70
|
+
fs.readFileSync(path.join(moduleRoot, 'api', 'channels', '[id]', 'ai-auto-reply', 'route.ts'), 'utf8')
|
|
71
|
+
|
|
72
|
+
it('scans a real, non-empty source tree', () => {
|
|
73
|
+
expect(files.length).toBeGreaterThan(20)
|
|
74
|
+
expect(files).toContain(path.join(moduleRoot, 'subscribers', 'ai-auto-reply.ts'))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('names the arming keys only in the files that read, validate, render or write them', () => {
|
|
78
|
+
const naming = files
|
|
79
|
+
.filter((file) => {
|
|
80
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
81
|
+
return ARMING_KEYS.some((key) => source.includes(key))
|
|
82
|
+
})
|
|
83
|
+
.map(relative)
|
|
84
|
+
.sort()
|
|
85
|
+
|
|
86
|
+
expect(naming).toEqual([...KEY_NAMERS].sort())
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('writes channelState from exactly two places — the gateway store and the settings command', () => {
|
|
90
|
+
const assigning = files
|
|
91
|
+
// `.channelState =` is the entity write; a bare `channelState =` is a local
|
|
92
|
+
// binding (the gateway worker parses one to read resume state).
|
|
93
|
+
.filter((file) => /\.channelState\s*=[^=]/.test(stripComments(fs.readFileSync(file, 'utf8'))))
|
|
94
|
+
.map(relative)
|
|
95
|
+
.sort()
|
|
96
|
+
|
|
97
|
+
// Any third writer is a deliberate addition that has to argue for itself:
|
|
98
|
+
// the gateway store carries the AI keys forward, and the settings command is
|
|
99
|
+
// the only thing allowed to change them.
|
|
100
|
+
expect(assigning).toEqual([WRITER, 'lib/channel-state-store.ts'].sort())
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('persists the settings through the command bus, not a direct entity write', () => {
|
|
104
|
+
const route = stripComments(readSettingsRoute())
|
|
105
|
+
expect(route).toContain('CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID')
|
|
106
|
+
expect(route).not.toMatch(/\.flush\(/)
|
|
107
|
+
expect(route).not.toMatch(/nativeUpdate/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('never invokes the agent with an empty feature set', () => {
|
|
111
|
+
const subscriber = stripComments(
|
|
112
|
+
fs.readFileSync(path.join(moduleRoot, 'subscribers', 'ai-auto-reply.ts'), 'utf8'),
|
|
113
|
+
)
|
|
114
|
+
expect(subscriber).not.toMatch(/features:\s*\[\s*\]/)
|
|
115
|
+
expect(subscriber).toContain('principal.features')
|
|
116
|
+
expect(subscriber).toContain('isSuperAdmin: principal.isSuperAdmin')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('refuses to arm a channel against an agent the auto-reply principal cannot invoke', () => {
|
|
120
|
+
// Structural, because the behavioural half lives in the route test and this
|
|
121
|
+
// is the property that must not quietly disappear: object-mode eligibility is
|
|
122
|
+
// a SHAPE check, and on its own it re-opens the dormancy hole — the runtime
|
|
123
|
+
// also enforces `requiredFeatures` against the principal from
|
|
124
|
+
// `lib/ai-service-principal.ts`, so the save path has to ask the same
|
|
125
|
+
// question with the same principal.
|
|
126
|
+
const route = stripComments(readSettingsRoute())
|
|
127
|
+
expect(route).toContain('missingAgentFeatures')
|
|
128
|
+
expect(route).toContain('resolveDiscordAiPrincipal')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('records why an armed channel produced nothing, instead of only logging it', () => {
|
|
132
|
+
const subscriber = stripComments(
|
|
133
|
+
fs.readFileSync(path.join(moduleRoot, 'subscribers', 'ai-auto-reply.ts'), 'utf8'),
|
|
134
|
+
)
|
|
135
|
+
// The no-op on failure is correct; being silent about it is what made the
|
|
136
|
+
// feature dormant. The marker write must survive any refactor of the catch.
|
|
137
|
+
expect(subscriber).toContain('recordDiscordAutoReplyOutcome')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('gates the write route on the configure feature, not merely on being signed in', () => {
|
|
141
|
+
// The settings route exposes GET and PUT from one file, so assert on the PUT
|
|
142
|
+
// metadata block specifically: a file-wide `toContain` would still pass if the
|
|
143
|
+
// configure feature only ever guarded the read half.
|
|
144
|
+
const putMetadata = readSettingsRoute().match(/\n {2}PUT: \{[\s\S]*?\n {2}\},/)?.[0]
|
|
145
|
+
expect(putMetadata).toBeDefined()
|
|
146
|
+
expect(putMetadata).toContain('requireAuth: true')
|
|
147
|
+
expect(putMetadata).toContain('CHANNEL_DISCORD_CONFIGURE_FEATURE')
|
|
148
|
+
})
|
|
149
|
+
})
|
package/src/modules/channel_discord/subscribers/__tests__/ai-auto-reply.failure-marker.test.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an armed Discord channel does when the agent call keeps being refused.
|
|
3
|
+
*
|
|
4
|
+
* The subscriber degrades to a no-op, and that part is right — a broken model
|
|
5
|
+
* must never become a send. The part that was wrong is that the no-op was
|
|
6
|
+
* *silent*: the settings surface still read "Auto-reply on", the channel answered
|
|
7
|
+
* nothing, and the only trace was a `logger.warn` in a background subscriber. A
|
|
8
|
+
* save-time feature check narrows the window but cannot close it, because a role
|
|
9
|
+
* edited after the channel was armed makes that verdict stale.
|
|
10
|
+
*
|
|
11
|
+
* So the contract here is: every failure leaves a reason on the channel, the next
|
|
12
|
+
* success clears it, and neither the marker write nor its failure can turn a
|
|
13
|
+
* degraded no-op into a thrown handler.
|
|
14
|
+
*/
|
|
15
|
+
const recordDiscordAutoReplyOutcomeMock = jest.fn(async () => 'written' as const)
|
|
16
|
+
|
|
17
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
18
|
+
findOneWithDecryption: jest.fn(),
|
|
19
|
+
}))
|
|
20
|
+
jest.mock('@open-mercato/core/modules/communication_channels/lib/system-user', () => ({
|
|
21
|
+
COMMUNICATION_CHANNELS_SYSTEM_USER_ID: '00000000-0000-0000-0000-000000000000',
|
|
22
|
+
resolveCommunicationChannelsSystemUserId: jest.fn(async () => 'system-user-id'),
|
|
23
|
+
}))
|
|
24
|
+
jest.mock('@open-mercato/ai-assistant', () => ({ runAiAgentObject: jest.fn() }), { virtual: true })
|
|
25
|
+
jest.mock('../../lib/channel-state-store', () => ({
|
|
26
|
+
recordDiscordAutoReplyOutcome: (...args: unknown[]) => recordDiscordAutoReplyOutcomeMock(...(args as [])),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
import handler from '../ai-auto-reply'
|
|
30
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
31
|
+
import { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../ai-agents'
|
|
32
|
+
|
|
33
|
+
const findOne = findOneWithDecryption as unknown as jest.Mock
|
|
34
|
+
const aiMod = jest.requireMock('@open-mercato/ai-assistant') as { runAiAgentObject: jest.Mock }
|
|
35
|
+
|
|
36
|
+
const payload = {
|
|
37
|
+
providerKey: 'discord' as const,
|
|
38
|
+
messageId: 'm-1',
|
|
39
|
+
channelId: 'c-1',
|
|
40
|
+
tenantId: 't-1',
|
|
41
|
+
organizationId: 'o-1',
|
|
42
|
+
direction: 'inbound' as const,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeCtx() {
|
|
46
|
+
const forked = { findOne: jest.fn(async () => null) }
|
|
47
|
+
const em = { fork: () => forked }
|
|
48
|
+
const commandBus = { execute: jest.fn(async () => ({ result: { id: 'reply-msg', threadId: null } })) }
|
|
49
|
+
const resolve = jest.fn((name: string) => {
|
|
50
|
+
if (name === 'em') return em
|
|
51
|
+
if (name === 'mcpToolRegistry') return {}
|
|
52
|
+
if (name === 'commandBus') return commandBus
|
|
53
|
+
return {}
|
|
54
|
+
})
|
|
55
|
+
return { ctx: { resolve }, commandBus }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function channelRow(channelState: Record<string, unknown>) {
|
|
59
|
+
return { id: 'c-1', tenantId: 't-1', organizationId: 'o-1', userId: null, channelState }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const armed = { aiAutoReplyEnabled: true, aiAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID }
|
|
63
|
+
|
|
64
|
+
function messageRow() {
|
|
65
|
+
return { id: 'm-1', threadId: 'thread-1', subject: 'Discord', type: 'channel.discord', body: 'Are you open?' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
jest.clearAllMocks()
|
|
70
|
+
recordDiscordAutoReplyOutcomeMock.mockResolvedValue('written')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('channel_discord ai-auto-reply — failure visibility', () => {
|
|
74
|
+
it('parks the reason on the channel when the runtime refuses the agent', async () => {
|
|
75
|
+
findOne.mockResolvedValueOnce(channelRow(armed)).mockResolvedValueOnce(messageRow())
|
|
76
|
+
aiMod.runAiAgentObject.mockRejectedValue(new Error('agent_features_denied'))
|
|
77
|
+
const { ctx, commandBus } = makeCtx()
|
|
78
|
+
|
|
79
|
+
await expect(handler(payload, ctx)).resolves.toBeUndefined()
|
|
80
|
+
|
|
81
|
+
// Still a no-op — nothing was sent and no proposal was filed.
|
|
82
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
83
|
+
expect(recordDiscordAutoReplyOutcomeMock).toHaveBeenCalledTimes(1)
|
|
84
|
+
const call = recordDiscordAutoReplyOutcomeMock.mock.calls[0][0] as {
|
|
85
|
+
channelId: string
|
|
86
|
+
scope: { tenantId: string; organizationId: string | null }
|
|
87
|
+
failure: string | null
|
|
88
|
+
}
|
|
89
|
+
expect(call.channelId).toBe('c-1')
|
|
90
|
+
expect(call.scope).toEqual({ tenantId: 't-1', organizationId: 'o-1' })
|
|
91
|
+
// The reason names the agent, because "which agent" is the first thing an
|
|
92
|
+
// operator staring at a silent channel needs to know.
|
|
93
|
+
expect(call.failure).toBe(`agent ${CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID}: agent_features_denied`)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('clears a stale marker on the next attempt that gets through', async () => {
|
|
97
|
+
findOne
|
|
98
|
+
.mockResolvedValueOnce(channelRow({ ...armed, aiAutoReplyLastError: 'agent x: denied' }))
|
|
99
|
+
.mockResolvedValueOnce(messageRow())
|
|
100
|
+
aiMod.runAiAgentObject.mockResolvedValue({
|
|
101
|
+
mode: 'generate',
|
|
102
|
+
object: { reply: 'We are open 9-5.', summary: 'Hours.', confidence: 0.9, requiresHuman: false },
|
|
103
|
+
})
|
|
104
|
+
const { ctx } = makeCtx()
|
|
105
|
+
|
|
106
|
+
await handler(payload, ctx)
|
|
107
|
+
|
|
108
|
+
expect(recordDiscordAutoReplyOutcomeMock).toHaveBeenCalledTimes(1)
|
|
109
|
+
expect((recordDiscordAutoReplyOutcomeMock.mock.calls[0][0] as { failure: string | null }).failure).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('does not pay a write on a healthy channel that has nothing to clear', async () => {
|
|
113
|
+
findOne.mockResolvedValueOnce(channelRow(armed)).mockResolvedValueOnce(messageRow())
|
|
114
|
+
aiMod.runAiAgentObject.mockResolvedValue({
|
|
115
|
+
mode: 'generate',
|
|
116
|
+
object: { reply: 'We are open 9-5.', summary: 'Hours.', confidence: 0.9, requiresHuman: false },
|
|
117
|
+
})
|
|
118
|
+
const { ctx } = makeCtx()
|
|
119
|
+
|
|
120
|
+
await handler(payload, ctx)
|
|
121
|
+
|
|
122
|
+
expect(recordDiscordAutoReplyOutcomeMock).not.toHaveBeenCalled()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('never lets the marker write itself escalate a no-op into a thrown handler', async () => {
|
|
126
|
+
findOne.mockResolvedValueOnce(channelRow(armed)).mockResolvedValueOnce(messageRow())
|
|
127
|
+
aiMod.runAiAgentObject.mockRejectedValue(new Error('boom'))
|
|
128
|
+
recordDiscordAutoReplyOutcomeMock.mockRejectedValue(new Error('database is down'))
|
|
129
|
+
const { ctx } = makeCtx()
|
|
130
|
+
|
|
131
|
+
await expect(handler(payload, ctx)).resolves.toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for the AI auto-reply subscriber's control flow (issue #4778).
|
|
3
|
+
*
|
|
4
|
+
* The AI runtime is stubbed here on purpose: `@open-mercato/ai-assistant` is a
|
|
5
|
+
* soft-optional peer, so a unit test that imported the real runtime would couple
|
|
6
|
+
* this package to it and break the decoupling property the subscriber exists to
|
|
7
|
+
* respect. What the stub cannot prove — that the REAL agent policy accepts the
|
|
8
|
+
* call the subscriber makes — is proven separately, against the real
|
|
9
|
+
* `agent-policy` / `agent-runtime`, in
|
|
10
|
+
* `packages/channel-discord/src/modules/channel_discord/__tests__/ai-auto-reply.policy.integration.test.ts`.
|
|
11
|
+
*
|
|
12
|
+
* The three tiers this file pins down:
|
|
13
|
+
* - easy + the model is confident and asks for no human → auto-send;
|
|
14
|
+
* - anything else → a proposal a human approves, never a send;
|
|
15
|
+
* - peer absent / channel not armed / malformed output → clean no-op.
|
|
16
|
+
*/
|
|
17
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
18
|
+
findOneWithDecryption: jest.fn(),
|
|
19
|
+
}))
|
|
20
|
+
jest.mock('@open-mercato/core/modules/communication_channels/lib/system-user', () => ({
|
|
21
|
+
COMMUNICATION_CHANNELS_SYSTEM_USER_ID: '00000000-0000-0000-0000-000000000000',
|
|
22
|
+
resolveCommunicationChannelsSystemUserId: jest.fn(async () => 'system-user-id'),
|
|
23
|
+
}))
|
|
24
|
+
jest.mock('@open-mercato/ai-assistant', () => ({ runAiAgentObject: jest.fn() }), { virtual: true })
|
|
25
|
+
|
|
26
|
+
import handler from '../ai-auto-reply'
|
|
27
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
28
|
+
import { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../ai-agents'
|
|
29
|
+
import { CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE } from '../../message-types'
|
|
30
|
+
|
|
31
|
+
const findOne = findOneWithDecryption as unknown as jest.Mock
|
|
32
|
+
const aiMod = jest.requireMock('@open-mercato/ai-assistant') as { runAiAgentObject: jest.Mock }
|
|
33
|
+
|
|
34
|
+
type ResolveMap = {
|
|
35
|
+
commandBus?: { execute: jest.Mock }
|
|
36
|
+
aiPresent?: boolean
|
|
37
|
+
threadAssignee?: string | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeCtx(map: ResolveMap) {
|
|
41
|
+
const forked = {
|
|
42
|
+
findOne: jest.fn(async () =>
|
|
43
|
+
map.threadAssignee ? { assignedUserId: map.threadAssignee } : null,
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
const em = { fork: () => forked }
|
|
47
|
+
const commandBus =
|
|
48
|
+
map.commandBus ?? { execute: jest.fn(async () => ({ result: { id: 'reply-msg', threadId: null } })) }
|
|
49
|
+
const resolve = jest.fn((name: string) => {
|
|
50
|
+
if (name === 'em') return em
|
|
51
|
+
if (name === 'mcpToolRegistry') {
|
|
52
|
+
if (map.aiPresent === false) throw new Error('ai_assistant not registered')
|
|
53
|
+
return {}
|
|
54
|
+
}
|
|
55
|
+
if (name === 'commandBus') return commandBus
|
|
56
|
+
return {}
|
|
57
|
+
})
|
|
58
|
+
return { ctx: { resolve }, commandBus, resolve, forked }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const basePayload = {
|
|
62
|
+
providerKey: 'discord' as const,
|
|
63
|
+
messageId: 'm-1',
|
|
64
|
+
channelId: 'c-1',
|
|
65
|
+
tenantId: 't-1',
|
|
66
|
+
organizationId: 'o-1',
|
|
67
|
+
direction: 'inbound' as const,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function channelRow(overrides: Record<string, unknown> = {}) {
|
|
71
|
+
return {
|
|
72
|
+
id: 'c-1',
|
|
73
|
+
tenantId: 't-1',
|
|
74
|
+
organizationId: 'o-1',
|
|
75
|
+
userId: null,
|
|
76
|
+
channelState: { aiAutoReplyEnabled: true, aiAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID },
|
|
77
|
+
...overrides,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function messageRow(body: string) {
|
|
82
|
+
return { id: 'm-1', threadId: 'thread-1', subject: 'Discord', type: 'channel.discord', body }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function agentOutput(overrides: Record<string, unknown> = {}) {
|
|
86
|
+
return {
|
|
87
|
+
mode: 'generate',
|
|
88
|
+
object: {
|
|
89
|
+
reply: 'We are open 9-5.',
|
|
90
|
+
summary: 'Asks about opening hours.',
|
|
91
|
+
confidence: 0.9,
|
|
92
|
+
requiresHuman: false,
|
|
93
|
+
...overrides,
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('channel_discord ai-auto-reply subscriber — cheap early returns', () => {
|
|
99
|
+
it('no-ops for a non-discord provider without touching the container', async () => {
|
|
100
|
+
const resolve = jest.fn(() => {
|
|
101
|
+
throw new Error('resolver should not be called')
|
|
102
|
+
})
|
|
103
|
+
await expect(
|
|
104
|
+
handler({ providerKey: 'gmail', messageId: 'm', channelId: 'c', tenantId: 't', direction: 'inbound' }, { resolve }),
|
|
105
|
+
).resolves.toBeUndefined()
|
|
106
|
+
expect(resolve).not.toHaveBeenCalled()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('no-ops for an outbound message', async () => {
|
|
110
|
+
const resolve = jest.fn(() => {
|
|
111
|
+
throw new Error('resolver should not be called')
|
|
112
|
+
})
|
|
113
|
+
await expect(
|
|
114
|
+
handler(
|
|
115
|
+
{ providerKey: 'discord', messageId: 'm', channelId: 'c', tenantId: 't', direction: 'outbound' },
|
|
116
|
+
{ resolve },
|
|
117
|
+
),
|
|
118
|
+
).resolves.toBeUndefined()
|
|
119
|
+
expect(resolve).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('no-ops when required payload fields are missing', async () => {
|
|
123
|
+
const resolve = jest.fn(() => {
|
|
124
|
+
throw new Error('resolver should not be called')
|
|
125
|
+
})
|
|
126
|
+
await expect(handler({ providerKey: 'discord', direction: 'inbound' }, { resolve })).resolves.toBeUndefined()
|
|
127
|
+
expect(resolve).not.toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('channel_discord ai-auto-reply subscriber — routing', () => {
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
findOne.mockReset()
|
|
134
|
+
aiMod.runAiAgentObject.mockReset()
|
|
135
|
+
aiMod.runAiAgentObject.mockResolvedValue(agentOutput())
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('(easy, confident) sends through the generic hub compose command', async () => {
|
|
139
|
+
findOne.mockResolvedValueOnce(channelRow()).mockResolvedValueOnce(messageRow('What are your opening hours?'))
|
|
140
|
+
const { ctx, commandBus } = makeCtx({ aiPresent: true })
|
|
141
|
+
|
|
142
|
+
await handler(basePayload, ctx)
|
|
143
|
+
|
|
144
|
+
expect(aiMod.runAiAgentObject).toHaveBeenCalledTimes(1)
|
|
145
|
+
expect(commandBus.execute).toHaveBeenCalledTimes(1)
|
|
146
|
+
const [commandId, args] = commandBus.execute.mock.calls[0]
|
|
147
|
+
expect(commandId).toBe('messages.messages.compose')
|
|
148
|
+
expect(args.input.body).toContain('We are open 9-5.')
|
|
149
|
+
expect(args.input.parentMessageId).toBe('thread-1')
|
|
150
|
+
expect(args.input.visibility).toBe('public')
|
|
151
|
+
expect(args.input.isDraft).toBe(false)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('runs the agent under a real service principal, never features: [] and never super-admin', async () => {
|
|
155
|
+
findOne.mockResolvedValueOnce(channelRow()).mockResolvedValueOnce(messageRow('What are your opening hours?'))
|
|
156
|
+
const { ctx } = makeCtx({ aiPresent: true })
|
|
157
|
+
|
|
158
|
+
await handler(basePayload, ctx)
|
|
159
|
+
|
|
160
|
+
const [call] = aiMod.runAiAgentObject.mock.calls
|
|
161
|
+
expect(call[0].agentId).toBe(CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID)
|
|
162
|
+
expect(call[0].authContext.features).toContain('channel_discord.ai_auto_reply.run')
|
|
163
|
+
expect(call[0].authContext.features.length).toBeGreaterThan(0)
|
|
164
|
+
expect(call[0].authContext.isSuperAdmin).toBe(false)
|
|
165
|
+
expect(call[0].authContext.tenantId).toBe('t-1')
|
|
166
|
+
expect(call[0].authContext.organizationId).toBe('o-1')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('(complex) drafts a proposal for a human and NEVER auto-sends', async () => {
|
|
170
|
+
findOne
|
|
171
|
+
.mockResolvedValueOnce(channelRow())
|
|
172
|
+
.mockResolvedValueOnce(messageRow('I want a refund on my order'))
|
|
173
|
+
.mockResolvedValueOnce(channelRow())
|
|
174
|
+
const { ctx, commandBus } = makeCtx({ aiPresent: true, threadAssignee: 'agent-user-1' })
|
|
175
|
+
|
|
176
|
+
await handler(basePayload, ctx)
|
|
177
|
+
|
|
178
|
+
// The agent still runs — the proposal has to contain a drafted reply.
|
|
179
|
+
expect(aiMod.runAiAgentObject).toHaveBeenCalledTimes(1)
|
|
180
|
+
expect(commandBus.execute).toHaveBeenCalledTimes(1)
|
|
181
|
+
const [, args] = commandBus.execute.mock.calls[0]
|
|
182
|
+
expect(args.input.type).toBe(CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE)
|
|
183
|
+
expect(args.input.visibility).toBe('internal')
|
|
184
|
+
expect(args.input.recipients).toEqual([{ userId: 'agent-user-1', type: 'to' }])
|
|
185
|
+
expect(args.input.sourceEntityId).toBe('m-1')
|
|
186
|
+
expect(args.input.body).toContain('We are open 9-5.')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('(complex, nobody assigned) stores the proposal as a draft rather than losing it', async () => {
|
|
190
|
+
findOne
|
|
191
|
+
.mockResolvedValueOnce(channelRow())
|
|
192
|
+
.mockResolvedValueOnce(messageRow('I want a refund on my order'))
|
|
193
|
+
.mockResolvedValueOnce(channelRow())
|
|
194
|
+
const { ctx, commandBus } = makeCtx({ aiPresent: true, threadAssignee: null })
|
|
195
|
+
|
|
196
|
+
await handler(basePayload, ctx)
|
|
197
|
+
|
|
198
|
+
const [, args] = commandBus.execute.mock.calls[0]
|
|
199
|
+
expect(args.input.isDraft).toBe(true)
|
|
200
|
+
expect(args.input.recipients).toEqual([])
|
|
201
|
+
expect(args.input.visibility).toBe('internal')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('(easy, model asks for a human) proposes instead of sending', async () => {
|
|
205
|
+
aiMod.runAiAgentObject.mockResolvedValue(agentOutput({ requiresHuman: true }))
|
|
206
|
+
findOne
|
|
207
|
+
.mockResolvedValueOnce(channelRow())
|
|
208
|
+
.mockResolvedValueOnce(messageRow('What are your opening hours?'))
|
|
209
|
+
.mockResolvedValueOnce(channelRow())
|
|
210
|
+
const { ctx, commandBus } = makeCtx({ aiPresent: true, threadAssignee: 'agent-user-1' })
|
|
211
|
+
|
|
212
|
+
await handler(basePayload, ctx)
|
|
213
|
+
|
|
214
|
+
const [, args] = commandBus.execute.mock.calls[0]
|
|
215
|
+
expect(args.input.type).toBe(CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('(easy, low confidence) proposes instead of sending', async () => {
|
|
219
|
+
aiMod.runAiAgentObject.mockResolvedValue(agentOutput({ confidence: 0.4 }))
|
|
220
|
+
findOne
|
|
221
|
+
.mockResolvedValueOnce(channelRow())
|
|
222
|
+
.mockResolvedValueOnce(messageRow('What are your opening hours?'))
|
|
223
|
+
.mockResolvedValueOnce(channelRow())
|
|
224
|
+
const { ctx, commandBus } = makeCtx({ aiPresent: true, threadAssignee: 'agent-user-1' })
|
|
225
|
+
|
|
226
|
+
await handler(basePayload, ctx)
|
|
227
|
+
|
|
228
|
+
const [, args] = commandBus.execute.mock.calls[0]
|
|
229
|
+
expect(args.input.type).toBe(CHANNEL_DISCORD_AI_PROPOSAL_MESSAGE_TYPE)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('(malformed agent output) degrades to a no-op instead of sending something unvalidated', async () => {
|
|
233
|
+
aiMod.runAiAgentObject.mockResolvedValue({ mode: 'generate', object: { reply: 'hi' } })
|
|
234
|
+
findOne.mockResolvedValueOnce(channelRow()).mockResolvedValueOnce(messageRow('What are your opening hours?'))
|
|
235
|
+
const { ctx, commandBus } = makeCtx({ aiPresent: true })
|
|
236
|
+
|
|
237
|
+
await expect(handler(basePayload, ctx)).resolves.toBeUndefined()
|
|
238
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('(no ai_assistant) is a clean no-op — no message load, no send', async () => {
|
|
242
|
+
findOne.mockResolvedValueOnce(channelRow())
|
|
243
|
+
const { ctx, commandBus } = makeCtx({ aiPresent: false })
|
|
244
|
+
|
|
245
|
+
await expect(handler(basePayload, ctx)).resolves.toBeUndefined()
|
|
246
|
+
|
|
247
|
+
expect(findOne).toHaveBeenCalledTimes(1) // channel only; message never fetched
|
|
248
|
+
expect(aiMod.runAiAgentObject).not.toHaveBeenCalled()
|
|
249
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('(disabled) no-ops when per-channel auto-reply is OFF (default)', async () => {
|
|
253
|
+
findOne.mockResolvedValueOnce(channelRow({ channelState: {} }))
|
|
254
|
+
const { ctx, commandBus } = makeCtx({ aiPresent: true })
|
|
255
|
+
|
|
256
|
+
await handler(basePayload, ctx)
|
|
257
|
+
|
|
258
|
+
expect(findOne).toHaveBeenCalledTimes(1)
|
|
259
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('loads the channel scoped by tenant + organization', async () => {
|
|
263
|
+
findOne.mockResolvedValueOnce(channelRow()).mockResolvedValueOnce(messageRow('hi there'))
|
|
264
|
+
const { ctx } = makeCtx({ aiPresent: true })
|
|
265
|
+
|
|
266
|
+
await handler(basePayload, ctx)
|
|
267
|
+
|
|
268
|
+
const [, entityArg, where, , dscope] = findOne.mock.calls[0]
|
|
269
|
+
expect(entityArg).toBeDefined()
|
|
270
|
+
expect(where).toMatchObject({ id: 'c-1', tenantId: 't-1', organizationId: 'o-1', deletedAt: null })
|
|
271
|
+
expect(dscope).toEqual({ tenantId: 't-1', organizationId: 'o-1' })
|
|
272
|
+
})
|
|
273
|
+
})
|