import { EventEmitter } from 'node:events'; import type { ChannelConversationRouteDto, ChannelEgressDto, ChannelIngressDto, ChannelIngressPort, } from '@mosaicstack/types'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createDiscordIngressEnvelope, DiscordPlugin, parseDiscordInteractionBindings, resolveDiscordInteractionActorId, resolveDiscordInteractionBinding, verifyDiscordIngressEnvelope, type DiscordIngressEnvelope, type DiscordInteractionRole, type DiscordPluginConfig, } from './index.js'; const SERVICE_TOKEN = 'test-service-token'; interface FakeDiscordMessageOptions { id?: string; guildId?: string; content: string; mentioned?: boolean; userId?: string; channelId?: string; parentChannelId?: string | null; isThread?: boolean; hasThread?: boolean; existingThreadId?: string; fetchedThreadId?: string; createdThreadId?: string; attachments?: Map; } interface FakeDiscordAttachment { id: string; name: string; url: string; contentType: string | null; size?: number; } interface FakeDiscordMessage { id: string; guildId: string; channelId: string; author: { id: string; bot: boolean }; mentions: { has(user: { id: string }): boolean }; content: string; createdAt: Date; channel: { parentId: string | null; isThread(): boolean; threads?: { fetch(id: string): Promise<{ id: string }> }; }; attachments: Map; hasThread: boolean; thread: { id: string } | null; startThread: ReturnType; } interface FakeGatewaySocket extends EventEmitter { connected: boolean; disconnect?: ReturnType; } interface FakeLifecycleClient extends EventEmitter { user: { id: string; tag: string }; login: ReturnType; destroy: ReturnType; isReady(): boolean; guilds: { cache: Map }; channels: { cache: Map }; } interface DiscordPluginInternals { client: { user: { id: string }; isReady(): boolean; channels?: { cache: { get(id: string): { send(options: unknown): Promise } | undefined }; }; }; socket: { connected: boolean; emit: ReturnType; } | null; conversationRoutes: Map; handleDiscordMessage(message: FakeDiscordMessage): void | Promise; sendToDiscord(conversationId: string, text: string): Promise; } function lifecyclePlugin(): { plugin: DiscordPlugin; socket: FakeGatewaySocket; client: FakeLifecycleClient; } { const socket = Object.assign(new EventEmitter(), { connected: false, disconnect: vi.fn(), }); const client = Object.assign(new EventEmitter(), { user: { id: 'bot-001', tag: 'mosaic-bot' }, login: vi.fn().mockResolvedValue('token'), destroy: vi.fn().mockResolvedValue(undefined), isReady: (): boolean => true, guilds: { cache: new Map() }, channels: { cache: new Map() }, }); const plugin = new DiscordPlugin( { token: 'unused', gatewayUrl: 'http://gateway.invalid', serviceToken: SERVICE_TOKEN, allowedGuildIds: ['guild-001'], allowedChannelIds: ['channel-001'], allowedUserIds: ['user-001'], interactionBindings: [ { instanceId: 'Nova', agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-user-001' }, }, }, ], }, { client: client as never, socketFactory: vi.fn().mockReturnValue(socket) as never, }, ); return { plugin, socket, client }; } function createPlugin( role: DiscordInteractionRole = 'operator', paired = true, ingressPort?: ChannelIngressPort, configOverrides: Partial = {}, ): { plugin: DiscordPlugin; internals: DiscordPluginInternals; emit: ReturnType; } { const plugin = new DiscordPlugin( { token: 'unused', gatewayUrl: 'http://unused', serviceToken: SERVICE_TOKEN, allowedGuildIds: ['guild-001'], allowedChannelIds: ['channel-001'], allowedUserIds: ['user-001'], interactionBindings: [ { instanceId: 'Nova', agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: paired ? { 'user-001': { role, mosaicUserId: 'mosaic-user-001' }, } : {}, }, ], ...configOverrides, }, { ingressPort }, ); const emit = vi.fn(); const internals = plugin as unknown as DiscordPluginInternals; internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true }; internals.socket = { connected: true, emit }; return { plugin, internals, emit }; } function fakeMessage(options: FakeDiscordMessageOptions): FakeDiscordMessage { const channelId = options.channelId ?? 'channel-001'; const parentChannelId = options.parentChannelId; const existingThreadId = options.existingThreadId; return { id: options.id ?? 'message-001', guildId: options.guildId ?? 'guild-001', channelId, author: { id: options.userId ?? 'user-001', bot: false }, mentions: { has: (): boolean => options.mentioned ?? false }, content: options.content, createdAt: new Date('2026-07-14T12:00:00.000Z'), channel: { parentId: parentChannelId ?? null, isThread: (): boolean => options.isThread ?? (parentChannelId !== undefined && parentChannelId !== null), ...(options.fetchedThreadId ? { threads: { fetch: vi.fn().mockResolvedValue({ id: options.fetchedThreadId }), }, } : {}), }, attachments: options.attachments ?? new Map(), hasThread: options.hasThread ?? existingThreadId !== undefined, thread: existingThreadId ? { id: existingThreadId } : null, startThread: vi.fn().mockResolvedValue({ id: options.createdThreadId ?? 'thread-created-001' }), }; } function emittedEnvelope(emit: ReturnType): DiscordIngressEnvelope { const call = emit.mock.calls[0] as [string, DiscordIngressEnvelope] | undefined; expect(call?.[0]).toBe('message'); expect(call?.[1]).toBeDefined(); return ( call?.[1] ?? createDiscordIngressEnvelope( { correlationId: 'unreachable', messageId: 'unreachable', guildId: 'unreachable', channelId: 'unreachable', userId: 'unreachable', conversationId: 'unreachable', content: 'unreachable', }, SERVICE_TOKEN, ) ); } afterEach((): void => { vi.useRealTimers(); vi.restoreAllMocks(); }); describe('Discord binding configuration', () => { it('parses role-only and Mosaic-linked pairings', () => { const bindings = parseDiscordInteractionBindings( JSON.stringify([ { instanceId: 'Nova', agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { 'legacy-user': 'operator', 'linked-user': { role: 'admin', mosaicUserId: 'mosaic-admin-001' }, 'linked-without-id': { role: 'operator' }, }, }, ]), ); expect(bindings).toHaveLength(1); expect(resolveDiscordInteractionActorId(bindings[0]!, 'legacy-user')).toBeNull(); expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-user')).toBe('mosaic-admin-001'); expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-without-id')).toBeNull(); expect(resolveDiscordInteractionActorId(bindings[0]!, 'missing-user')).toBeNull(); expect( resolveDiscordInteractionBinding(bindings, 'guild-001', 'channel-001', 'linked-user', 'bind'), ).toBe(bindings[0]); expect( resolveDiscordInteractionBinding( bindings, 'guild-001', 'channel-001', 'linked-without-id', 'attach', ), ).toBe(bindings[0]); expect( resolveDiscordInteractionBinding( bindings, 'other-guild', 'channel-001', 'linked-user', 'send', ), ).toBeNull(); }); it.each([ ['missing value', undefined], ['empty array', '[]'], ['non-object binding', '[null]'], ['missing binding fields', '[{"instanceId":"Nova"}]'], [ 'empty Discord user ID', '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"":"operator"}}]', ], [ 'invalid role-only pairing', '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":"owner"}}]', ], [ 'non-object pairing', '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":42}}]', ], [ 'invalid linked pairing', '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":{"role":"admin","mosaicUserId":" "}}}]', ], ])('rejects %s', (_case: string, value: string | undefined) => { expect(() => parseDiscordInteractionBindings(value)).toThrow(); }); }); describe('Discord ingress integrity', () => { it.each([ ['guild', { guildIds: ['other'], channelIds: ['channel-001'], userIds: ['user-001'] }], ['channel', { guildIds: ['guild-001'], channelIds: ['other'], userIds: ['user-001'] }], ['user', { guildIds: ['guild-001'], channelIds: ['channel-001'], userIds: ['other'] }], ])('rejects an unallowlisted %s', (_field: string, allowlists) => { const payload = { correlationId: 'correlation-001', messageId: 'message-001', guildId: 'guild-001', channelId: 'channel-001', userId: 'user-001', conversationId: 'Nova:discord:channel-001', content: 'hello', }; const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN); expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, allowlists)).toBeNull(); }); }); describe('Discord adapter lifecycle', () => { it('starts while the in-process gateway is still connecting, then reports connected', async () => { const { plugin, socket, client } = lifecyclePlugin(); await plugin.start(); expect(client.login).toHaveBeenCalledWith('unused'); expect(await plugin.health()).toEqual({ status: 'degraded' }); socket.connected = true; socket.emit('connect'); expect(await plugin.health()).toEqual({ status: 'connected' }); await plugin.stop(); expect(socket.disconnect).toHaveBeenCalledOnce(); expect(client.destroy).toHaveBeenCalledOnce(); }); it('keeps running while Socket.IO reconnects after an initial gateway error', async () => { const { plugin, socket } = lifecyclePlugin(); const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined); await plugin.start(); socket.emit('connect_error', new Error('gateway not listening yet')); expect(error).toHaveBeenCalledWith( '[discord] Gateway connection error: gateway not listening yet', ); expect(await plugin.health()).toEqual({ status: 'degraded' }); }); it('cleans up when Discord login fails', async () => { const { plugin, socket, client } = lifecyclePlugin(); client.login.mockRejectedValueOnce(new Error('Discord authentication rejected')); await expect(plugin.start()).rejects.toThrow('Discord authentication rejected'); expect(socket.disconnect).toHaveBeenCalledOnce(); expect(client.destroy).toHaveBeenCalledOnce(); }); }); describe('Discord project channel provisioning', () => { it('returns null without a configured guild or visible guild', async () => { const { plugin } = createPlugin(); await expect( plugin.createProjectChannel({ id: 'project-1', name: 'Alpha' }), ).resolves.toBeNull(); const { client } = lifecyclePlugin(); const configured = new DiscordPlugin( { token: 'unused', gatewayUrl: 'http://unused', serviceToken: SERVICE_TOKEN, guildId: 'missing-guild', allowedGuildIds: ['guild-001'], allowedChannelIds: ['channel-001'], allowedUserIds: ['user-001'], interactionBindings: [], }, { client: client as never }, ); await expect( configured.createProjectChannel({ id: 'project-1', name: 'Alpha' }), ).resolves.toBeNull(); }); it('creates a normalized Discord project channel', async () => { const create = vi.fn().mockResolvedValue({ id: 'created-channel-001' }); const { client } = lifecyclePlugin(); client.guilds.cache.set('guild-001', { channels: { create } }); const plugin = new DiscordPlugin( { token: 'unused', gatewayUrl: 'http://unused', serviceToken: SERVICE_TOKEN, guildId: 'guild-001', allowedGuildIds: ['guild-001'], allowedChannelIds: ['channel-001'], allowedUserIds: ['user-001'], interactionBindings: [], }, { client: client as never }, ); await expect( plugin.createProjectChannel({ id: 'project-1', name: ' Project Alpha! ', description: 'Alpha workspace', }), ).resolves.toEqual({ channelId: 'created-channel-001' }); expect(create).toHaveBeenCalledWith( expect.objectContaining({ name: 'mosaic-project-alpha', topic: 'Alpha workspace' }), ); await plugin.createProjectChannel({ id: 'project-2', name: 'Beta' }); expect(create).toHaveBeenLastCalledWith( expect.objectContaining({ topic: 'Mosaic project: Beta' }), ); }); }); function egressFor( route: ChannelConversationRouteDto, content = 'agent response', ): ChannelEgressDto { return { correlationId: 'egress-correlation-001', route, message: { id: 'egress-message-001', channelName: 'discord', channelId: route.responseTarget.channelId, senderId: route.logicalAgentId, senderKind: 'agent', content, contentKind: 'markdown', timestamp: '2026-07-14T12:00:00.000Z', metadata: {}, }, }; } describe('official Discord channel routing', () => { it('normalizes an authorized turn through the shared channel ingress port', async () => { const receive = vi.fn<(ingress: ChannelIngressDto) => Promise>().mockResolvedValue(); const { internals, emit } = createPlugin('operator', true, { receive }); internals.socket = null; await internals.handleDiscordMessage(fakeMessage({ content: 'normalized turn' })); expect(emit).not.toHaveBeenCalled(); expect(receive).toHaveBeenCalledWith( expect.objectContaining({ operation: 'message.send', principal: { channelUserId: 'user-001', role: 'operator', mosaicUserId: 'mosaic-user-001', }, message: expect.objectContaining({ channelName: 'discord', channelId: 'channel-001', content: 'normalized turn', senderKind: 'user', }), route: expect.objectContaining({ logicalAgentId: 'Nova', conversationId: 'Nova:discord:channel-001', }), }), ); }); it('releases a response route when typed ingress rejects', async () => { const receive = vi.fn().mockRejectedValue(new Error('gateway rejected ingress')); const { internals } = createPlugin('operator', true, { receive }); await expect( internals.handleDiscordMessage(fakeMessage({ content: 'rejected typed ingress' })), ).rejects.toThrow('gateway rejected ingress'); expect(internals.conversationRoutes).toHaveLength(0); }); it('routes an authorized untagged parent-channel message and responds in that channel', async () => { const { internals, emit } = createPlugin(); const message = fakeMessage({ content: 'channel conversation' }); await internals.handleDiscordMessage(message); expect(message.startThread).not.toHaveBeenCalled(); const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN); expect(payload).toMatchObject({ channelId: 'channel-001', conversationId: 'Nova:discord:channel-001', content: 'channel conversation', }); expect(payload?.threadId).toBeUndefined(); }); it('creates a thread for a mentioned parent-channel message and routes the response there', async () => { const { internals, emit } = createPlugin(); const message = fakeMessage({ content: '<@bot-001> investigate this topic', mentioned: true, createdThreadId: 'thread-created-001', }); await internals.handleDiscordMessage(message); expect(message.startThread).toHaveBeenCalledOnce(); const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN); expect(payload).toMatchObject({ channelId: 'channel-001', conversationId: 'Nova:discord:thread-created-001', content: 'investigate this topic', threadId: 'thread-created-001', }); }); it('fetches and reuses an existing thread that is missing from the cache', async () => { const { internals, emit } = createPlugin(); const message = fakeMessage({ content: '<@bot-001> continue uncached topic', mentioned: true, hasThread: true, fetchedThreadId: 'thread-fetched-001', }); await internals.handleDiscordMessage(message); expect(message.startThread).not.toHaveBeenCalled(); expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ conversationId: 'Nova:discord:thread-fetched-001', threadId: 'thread-fetched-001', }); }); it('delivers the agent response to the thread selected by the mentioned turn', async () => { const { internals } = createPlugin(); const send = vi.fn().mockResolvedValue(undefined); internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true, channels: { cache: { get: (id: string): { send(options: unknown): Promise } | undefined => id === 'thread-response-001' ? { send } : undefined, }, }, }; await internals.handleDiscordMessage( fakeMessage({ content: '<@bot-001> threaded response', mentioned: true, createdThreadId: 'thread-response-001', }), ); await internals.sendToDiscord('Nova:discord:thread-response-001', 'agent answer'); expect(send).toHaveBeenCalledWith( expect.objectContaining({ content: 'agent answer', enforceNonce: true }), ); }); it('reuses a thread already attached to a mentioned message instead of creating another', async () => { const { internals, emit } = createPlugin(); const message = fakeMessage({ content: '<@bot-001> continue existing topic', mentioned: true, existingThreadId: 'thread-existing-001', }); await internals.handleDiscordMessage(message); expect(message.startThread).not.toHaveBeenCalled(); expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ conversationId: 'Nova:discord:thread-existing-001', threadId: 'thread-existing-001', }); }); it('keeps an untagged follow-up inside an authorized thread without nesting threads', async () => { const { internals, emit } = createPlugin(); const message = fakeMessage({ content: 'thread follow-up', channelId: 'thread-001', parentChannelId: 'channel-001', }); await internals.handleDiscordMessage(message); expect(message.startThread).not.toHaveBeenCalled(); expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ channelId: 'channel-001', conversationId: 'Nova:discord:thread-001', content: 'thread follow-up', threadId: 'thread-001', }); }); it.each([ ['guild', { guildId: 'guild-not-allowed' }], ['channel', { channelId: 'channel-not-allowed' }], ['user', { userId: 'user-not-allowed' }], ])( 'rejects an unauthorized %s before thread creation or gateway dispatch', async (_boundary: string, override: Partial) => { const { internals, emit } = createPlugin(); const message = fakeMessage({ content: '<@bot-001> unauthorized topic', mentioned: true, ...override, }); await internals.handleDiscordMessage(message); expect(message.startThread).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled(); }, ); it.each([ ['parent channel', {}], ['existing thread', { channelId: 'thread-unpaired-001', parentChannelId: 'channel-001' }], ])( 'rejects an allowlisted but unpaired user in %s', async (_location: string, override: Partial) => { const { internals, emit } = createPlugin('operator', false); const message = fakeMessage({ content: 'unpaired message', ...override }); await internals.handleDiscordMessage(message); expect(message.startThread).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled(); }, ); it('rejects a paired viewer before thread creation or gateway dispatch', async () => { const { internals, emit } = createPlugin('viewer'); const message = fakeMessage({ content: '<@bot-001> viewer cannot send', mentioned: true, }); await internals.handleDiscordMessage(message); expect(message.startThread).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled(); }); it('uses a normal channel ID rather than its category parent for authorization', async () => { const { internals, emit } = createPlugin(); const message = fakeMessage({ content: 'message from categorized channel', parentChannelId: 'category-001', isThread: false, }); await internals.handleDiscordMessage(message); expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ channelId: 'channel-001', conversationId: 'Nova:discord:channel-001', }); }); it.each([ ['parent channel', {}], ['existing thread', { channelId: 'thread-attachment-001', parentChannelId: 'channel-001' }], ])( 'preserves an attachment-only turn in an authorized %s', async (_location: string, override: Partial) => { const { internals, emit } = createPlugin(); const attachments = new Map([ [ 'attachment-001', { id: 'attachment-001', name: 'diagram.png', url: 'https://cdn.example.invalid/diagram.png', contentType: 'image/png', size: 4_096, }, ], ]); await internals.handleDiscordMessage(fakeMessage({ content: '', attachments, ...override })); expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ content: '', attachments: [ { id: 'attachment-001', name: 'diagram.png', contentType: 'image/png', sizeBytes: 4_096, }, ], }); }, ); it('does not dispatch when Discord cannot create the requested thread', async () => { const { internals, emit } = createPlugin(); const message = fakeMessage({ content: '<@bot-001> new topic', mentioned: true }); message.startThread.mockRejectedValueOnce(new Error('missing thread permission')); await expect(internals.handleDiscordMessage(message)).rejects.toThrow( 'missing thread permission', ); expect(emit).not.toHaveBeenCalled(); }); it.each(['/approve', '/stop approval-001'])( 'rejects operator use of privileged command %s before gateway dispatch', async (command: string) => { const { internals, emit } = createPlugin('operator'); const message = fakeMessage({ content: `<@bot-001> ${command}`, mentioned: true }); await internals.handleDiscordMessage(message); expect(message.startThread).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled(); }, ); it('keeps runtime control commands on the current durable session', async () => { const { internals, emit } = createPlugin('admin'); const message = fakeMessage({ content: '<@bot-001> /approve', mentioned: true }); await internals.handleDiscordMessage(message); expect(message.startThread).not.toHaveBeenCalled(); expect(emit).toHaveBeenCalledWith('discord:approve', expect.any(Object)); }); it('keeps the stable conversation address independent of a runtime harness', async () => { const first = createPlugin(); const second = createPlugin(); await first.internals.handleDiscordMessage( fakeMessage({ id: 'message-claude', content: 'before runtime handoff' }), ); await second.internals.handleDiscordMessage( fakeMessage({ id: 'message-pi', content: 'after runtime handoff' }), ); const firstPayload = verifyDiscordIngressEnvelope(emittedEnvelope(first.emit), SERVICE_TOKEN); const secondPayload = verifyDiscordIngressEnvelope(emittedEnvelope(second.emit), SERVICE_TOKEN); expect(firstPayload?.conversationId).toBe('Nova:discord:channel-001'); expect(secondPayload?.conversationId).toBe(firstPayload?.conversationId); }); it('rate-limits authorized turns before thread creation or dispatch', async () => { const { internals, emit } = createPlugin('operator', true, undefined, { messageRateLimitPerMinute: 1, threadRateLimitPerMinute: 1, }); const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined); const first = fakeMessage({ id: 'rate-first', content: 'first turn' }); const second = fakeMessage({ id: 'rate-second', content: '<@bot-001> second turn', mentioned: true, }); await internals.handleDiscordMessage(first); await internals.handleDiscordMessage(second); expect(emit).toHaveBeenCalledOnce(); expect(second.startThread).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith(expect.stringContaining('Message rate limit reached')); }); it('applies a stricter mention-thread rate limit before Discord side effects', async () => { const { internals, emit } = createPlugin('operator', true, undefined, { messageRateLimitPerMinute: 10, threadRateLimitPerMinute: 1, }); vi.spyOn(console, 'error').mockImplementation((): void => undefined); const first = fakeMessage({ id: 'thread-rate-first', content: 'first topic', mentioned: true }); const second = fakeMessage({ id: 'thread-rate-second', content: 'second topic', mentioned: true, }); await internals.handleDiscordMessage(first); await internals.handleDiscordMessage(second); expect(first.startThread).toHaveBeenCalledOnce(); expect(second.startThread).not.toHaveBeenCalled(); expect(emit).toHaveBeenCalledOnce(); }); it('rejects unconfigured egress and handles missing or failed Discord destinations', async () => { const { plugin, internals } = createPlugin(); const route: ChannelConversationRouteDto = { bindingId: 'guild-001:channel-001:Nova', logicalAgentId: 'Nova', conversationId: 'Nova:discord:channel-001', channelName: 'discord', authorizationChannelId: 'channel-001', responseTarget: { channelId: 'channel-001' }, }; await expect( plugin.send( egressFor({ ...route, bindingId: 'forged-binding', }), ), ).rejects.toMatchObject({ code: 'invalid_route' }); await expect( plugin.send( egressFor({ ...route, conversationId: 'Nova:discord:unrelated-conversation', }), ), ).rejects.toMatchObject({ code: 'invalid_route' }); await expect( plugin.send({ ...egressFor(route), message: { ...egressFor(route).message, channelId: 'other-channel' }, }), ).rejects.toMatchObject({ code: 'invalid_route' }); const forgedSend = vi.fn().mockResolvedValue(undefined); internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true, channels: { cache: { get: (): { send(options: unknown): Promise } => ({ send: forgedSend }), }, }, }; await expect( plugin.send( egressFor({ ...route, conversationId: 'Nova:discord:forged-channel', responseTarget: { channelId: 'forged-channel', threadId: 'forged-channel' }, }), ), ).rejects.toMatchObject({ code: 'invalid_route' }); expect(forgedSend).not.toHaveBeenCalled(); internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true, channels: { cache: { get: (): undefined => undefined } }, }; await expect(plugin.send(egressFor(route))).rejects.toMatchObject({ code: 'destination_unavailable', }); await expect( internals.sendToDiscord('missing-conversation', 'missing route'), ).rejects.toMatchObject({ code: 'invalid_route' }); vi.useFakeTimers(); const send = vi .fn() .mockRejectedValue(Object.assign(new Error('Discord unavailable'), { status: 503 })); internals.client.channels = { cache: { get: (): { send(options: unknown): Promise } => ({ send }) }, }; const delivery = plugin.send(egressFor(route)); const rejection = expect(delivery).rejects.toMatchObject({ code: 'delivery_failed', retryable: true, }); await vi.runAllTimersAsync(); await rejection; expect(send).toHaveBeenCalledTimes(3); expect(new Set(send.mock.calls.map(([options]) => options.nonce)).size).toBe(1); expect(send).toHaveBeenCalledWith( expect.objectContaining({ enforceNonce: true, content: 'agent response' }), ); const permanentSend = vi .fn() .mockRejectedValue(Object.assign(new Error('Discord forbidden'), { status: 403 })); internals.client.channels = { cache: { get: (): { send(options: unknown): Promise } => ({ send: permanentSend }), }, }; await expect(plugin.send(egressFor(route))).rejects.toMatchObject({ code: 'delivery_failed', retryable: false, }); expect(permanentSend).toHaveBeenCalledOnce(); }); it('chunks long egress responses at Discord-safe boundaries', async () => { const { plugin, internals } = createPlugin(); const send = vi.fn().mockResolvedValue(undefined); internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true, channels: { cache: { get: (): { send(options: unknown): Promise } => ({ send }) }, }, }; const route: ChannelConversationRouteDto = { bindingId: 'guild-001:channel-001:Nova', logicalAgentId: 'Nova', conversationId: 'Nova:discord:channel-001', channelName: 'discord', authorizationChannelId: 'channel-001', responseTarget: { channelId: 'channel-001' }, }; await plugin.send(egressFor(route, `${'a'.repeat(1_500)}\n${'b'.repeat(1_500)}`)); expect(send).toHaveBeenCalledTimes(2); }); it('reports channel adapter health without exposing runtime-provider state', async () => { const { plugin, internals } = createPlugin(); expect(await plugin.health()).toEqual({ status: 'connected' }); internals.socket = { connected: false, emit: vi.fn() }; expect(await plugin.health()).toEqual({ status: 'degraded' }); internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => false }; expect(await plugin.health()).toEqual({ status: 'disconnected' }); internals.socket = { connected: true, emit: vi.fn() }; expect(await plugin.health()).toEqual({ status: 'degraded' }); }); });