Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e415e31
fix(ai): answer each ticket once, and stop leaking ticket IDs to repo…
NathanTarbert Aug 11, 2026
ae314de
fix(teams-bot): use real em dash in two card doc comments
NathanTarbert Aug 11, 2026
4dfde3c
fix(teams): drop dead ticketDisplayId from response card payloads
NathanTarbert Aug 11, 2026
ebc503a
test(teams-bot): make the displayId leak guards able to fail
NathanTarbert Aug 11, 2026
747cce0
fix(inbound): one shared reopen-status set for all reply paths
NathanTarbert Aug 11, 2026
202fc77
fix(inbound): build and look up Ticket.sourceId with one shared helper
NathanTarbert Aug 11, 2026
0052d36
fix(inbound): never answer an orphaned reply
NathanTarbert Aug 11, 2026
508e904
fix(ai): escalate when an AI answer never reaches the reporter
NathanTarbert Aug 11, 2026
f03bd56
fix(queue): answer the message that opened the ticket
NathanTarbert Aug 11, 2026
57cbd28
fix(queue): finish the skipped AI_RESPONSE job at 100% progress
NathanTarbert Aug 11, 2026
7bd88b2
test(queue): pin both halves of the one-response-per-ticket guard
NathanTarbert Aug 11, 2026
aa8b98f
test: repoint team-member assertions off the reply path
NathanTarbert Aug 11, 2026
ee502a0
docs: correct the one-response-per-ticket comments to match the code
NathanTarbert Aug 11, 2026
e02f620
Merge branch 'main' into fix/one-ai-response-per-ticket
NathanTarbert Aug 12, 2026
aa95e94
fix(teams): gate replies by monitored channel
NathanTarbert Aug 12, 2026
9180591
fix(inbound): keep the bot silent on an orphaned reply
NathanTarbert Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 13 additions & 28 deletions apps/discord-bot/src/__tests__/message-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ describe('handleMessageCreate', () => {
expect(prisma.message.create).not.toHaveBeenCalled();
});

it('processes reply through InboundHandler and enqueues AI response for non-team-member', async () => {
// ONE RESPONSE PER TICKET. The thread starter gets an answer (see
// thread-create.test.ts); replies in that thread never do, whoever sends
// them. This test used to assert the opposite — it locked in the behaviour
// where the bot answered follow-up messages, including a maintainer's own
// reply in a Discord support thread.
it('appends a reply through InboundHandler without enqueuing an AI response', async () => {
const message = makeMessage();
await handleMessageCreate(message);

Expand All @@ -114,36 +119,16 @@ describe('handleMessageCreate', () => {
}),
});

// InboundHandler enqueues AI response via createJob wrapper
expect(createJob).toHaveBeenCalledWith(
'AI_RESPONSE',
expect.objectContaining({
ticketId: 'ticket-1',
source: 'discord',
}),
);
});

it('does not enqueue AI response for team member messages', async () => {
// Set up as team member
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: 'u-1',
email: 'team@copilotkit.ai',
} as ReturnType<typeof prisma.user.findFirst> extends Promise<infer T> ? T : never);
vi.mocked(prisma.teamMember.findUnique).mockResolvedValue({
id: 'tm-1',
} as ReturnType<typeof prisma.teamMember.findUnique> extends Promise<infer T> ? T : never);

const message = makeMessage();
await handleMessageCreate(message);

// Should still save the message
expect(prisma.message.create).toHaveBeenCalled();

// Should NOT enqueue an AI response
expect(createJob).not.toHaveBeenCalled();
});

// No "does not enqueue AI response for team member messages" test here:
// every message this handler sees is a thread reply, and replies never
// enqueue for any sender, so it would pass with team-member detection
// removed entirely. The sender-dependent assertion now lives on the
// new-ticket path — see 'a team member opening a thread gets a ticket but
// no AI response' in thread-create.test.ts.

it('reopens ticket when customer replies to a resolved ticket', async () => {
vi.mocked(prisma.ticket.findFirst).mockResolvedValue({
...TICKET,
Expand Down
72 changes: 60 additions & 12 deletions apps/discord-bot/src/__tests__/shadow-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@ import { mockPrisma, mockQueue } from './helpers/mocks.js';
vi.mock('@copilotkit/outpost/db', () => mockPrisma());
vi.mock('@copilotkit/outpost/queue', () => mockQueue());

vi.mock('@copilotkit/outpost/shared', () => ({
truncate: vi.fn((str: string, _len: number) => str),
}));
// truncate is stubbed to a pass-through so assertions can compare exact
// strings, but buildTicketSourceId/TicketSource stay REAL: the point of the
// sourceId assertions below is that shadow mode derives the key with the shared
// helper, which a stub would hide.
vi.mock('@copilotkit/outpost/shared', async (importOriginal) => {
const actual = await importOriginal<typeof import('@copilotkit/outpost/shared')>();
return {
...actual,
truncate: vi.fn((str: string, _len: number) => str),
};
});

import {
isShadowMode,
Expand All @@ -18,6 +26,7 @@ import {
} from '../lib/shadow-mode.js';
import { prisma } from '@copilotkit/outpost/db';
import { createJob, JobType } from '@copilotkit/outpost/queue';
import { TicketSource, buildTicketSourceId } from '@copilotkit/outpost/shared';

function makeThread(overrides: Record<string, unknown> = {}) {
return {
Expand Down Expand Up @@ -130,6 +139,49 @@ describe('shadow-mode', () => {
});
});

// The writer must derive sourceId with buildTicketSourceId, not inline
// thread.id: findTicketByThreadId reads through that helper, so an
// inlined write silently desynchronizes the moment the derivation
// changes (exactly the null-vs-'' bug this helper was introduced for).
it('derives sourceId with buildTicketSourceId, not an inlined thread.id', async () => {
const thread = makeThread({ id: 'thread-999' });
await handleShadowThreadCreate(
thread,
'TKT-0001',
'My question',
'TestUser#1234',
'user-456',
);

const { data } = vi.mocked(prisma.ticket.create).mock.calls[0]![0] as {
data: { sourceId: string | null };
};
expect(data.sourceId).toBe(
buildTicketSourceId(TicketSource.DISCORD, 'thread-999'),
);
expect(data.sourceId).toBe('thread-999');
});

it('stores a null sourceId for an unaddressable thread', async () => {
// No thread key means no reply can ever find this ticket. We still
// create it (never drop the report) but must store the helper's null
// rather than an empty-string placeholder a reader would search for.
const thread = makeThread({ id: '' });
const result = await handleShadowThreadCreate(
thread,
'TKT-0001',
'My question',
'TestUser#1234',
'user-456',
);

expect(result).toBe('ticket-internal-id');
const { data } = vi.mocked(prisma.ticket.create).mock.calls[0]![0] as {
data: { sourceId: string | null };
};
expect(data.sourceId).toBeNull();
});

it('creates a message record for the content', async () => {
const thread = makeThread();
await handleShadowThreadCreate(
Expand Down Expand Up @@ -230,18 +282,14 @@ describe('shadow-mode', () => {
});
});

it('enqueues an AI response job for the ticket', async () => {
// Shadow mode has to mirror production, and production answers a ticket
// once — on its opening message. Enqueuing on replies here would make
// shadow traffic look chattier than the real bot.
it('does not enqueue an AI response job for a reply', async () => {
const message = makeMessage();
await handleShadowMessage(message, 'ticket-1', 'thread-123');

expect(createJob).toHaveBeenCalledWith(
JobType.AI_RESPONSE,
expect.objectContaining({
ticketId: 'ticket-1',
threadId: 'thread-123',
source: 'discord',
}),
);
expect(createJob).not.toHaveBeenCalled();
});
});
});
44 changes: 44 additions & 0 deletions apps/discord-bot/src/__tests__/thread-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ vi.mock('discord.js', async (importOriginal) => {
import { handleThreadCreate } from '../events/thread-create.js';
import { prisma } from '@copilotkit/outpost/db';
import { createJob } from '@copilotkit/outpost/queue';
import { PlatformDiscordAdapter } from '@copilotkit/outpost/shared/platforms';

function makeThread(overrides: Record<string, unknown> = {}) {
return {
Expand Down Expand Up @@ -127,6 +128,49 @@ describe('handleThreadCreate', () => {
);
});

// The new-ticket path is the one place where the sender still decides
// whether an AI job is enqueued: a community reporter's thread gets an
// answer (test above), a team member's does not. Replies never enqueue for
// anyone, so this assertion cannot live on the reply path.
it('creates a ticket but does not enqueue an AI response when a team member opens the thread', async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: 'u-1',
email: 'team@copilotkit.ai',
} as ReturnType<typeof prisma.user.findFirst> extends Promise<infer T> ? T : never);
vi.mocked(prisma.teamMember.findUnique).mockResolvedValue({
id: 'tm-1',
} as ReturnType<typeof prisma.teamMember.findUnique> extends Promise<infer T> ? T : never);

const thread = makeThread();
await handleThreadCreate(thread, true);

// The ticket and its first message are still recorded.
expect(prisma.ticket.create).toHaveBeenCalled();
expect(prisma.message.create).toHaveBeenCalled();

// But the bot does not answer its own team.
expect(createJob).not.toHaveBeenCalled();
});

// The bot used to open every thread with "🎫 Ticket TKT-XXXXXXXX created…",
// publishing an internal identifier into a public server and spending a bot
// message on nothing the reporter can act on. The AI answer is the only
// message the bot sends.
it('posts no acknowledgment message and never emits the ticket displayId', async () => {
const postSystemMessage = vi.spyOn(
PlatformDiscordAdapter.prototype,
'postSystemMessage',
);

const thread = makeThread();
await handleThreadCreate(thread, true);

expect(postSystemMessage).not.toHaveBeenCalled();
expect(thread.send).not.toHaveBeenCalled();

postSystemMessage.mockRestore();
});

it('handles threads with no starter message content gracefully', async () => {
const thread = makeThread();
vi.mocked(thread.fetchStarterMessage).mockResolvedValue(null);
Expand Down
96 changes: 96 additions & 0 deletions apps/discord-bot/src/__tests__/tickets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPrisma } from './helpers/mocks.js';

vi.mock('@copilotkit/outpost/db', () => mockPrisma());

import { findTicketByThreadId, isTeamMember } from '../lib/tickets.js';
import { prisma } from '@copilotkit/outpost/db';
import { TicketSource, buildTicketSourceId } from '@copilotkit/outpost/shared';

describe('findTicketByThreadId', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('queries by the sourceId buildTicketSourceId derives', async () => {
vi.mocked(prisma.ticket.findFirst).mockResolvedValue(null);

await findTicketByThreadId('thread-123');

expect(prisma.ticket.findFirst).toHaveBeenCalledWith({
where: {
source: 'DISCORD',
sourceId: buildTicketSourceId(TicketSource.DISCORD, 'thread-123'),
},
});
// Pinned literal too, so a derivation change has to be a deliberate act
// in both writer and reader rather than a silently-agreeing tautology.
expect(prisma.ticket.findFirst).toHaveBeenCalledWith({
where: { source: 'DISCORD', sourceId: 'thread-123' },
});
});

it('returns the ticket when found', async () => {
const ticket = { id: 'ticket-1', source: 'DISCORD', sourceId: 'thread-123' };
vi.mocked(prisma.ticket.findFirst).mockResolvedValue(
ticket as ReturnType<typeof prisma.ticket.findFirst> extends Promise<infer T> ? T : never,
);

expect(await findTicketByThreadId('thread-123')).toEqual(ticket);
});

it('returns null when no ticket found', async () => {
vi.mocked(prisma.ticket.findFirst).mockResolvedValue(null);

expect(await findTicketByThreadId('nonexistent')).toBeNull();
});

it('skips the query entirely for an unaddressable thread', async () => {
// buildTicketSourceId yields no key for an empty thread ID. Falling
// through to `sourceId: null` would match any keyless row and hand back
// an unrelated ticket, so the lookup must not run at all.
vi.mocked(prisma.ticket.findFirst).mockResolvedValue(null);

expect(await findTicketByThreadId('')).toBeNull();
expect(prisma.ticket.findFirst).not.toHaveBeenCalled();
});
});

describe('isTeamMember', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns true when the user maps to a TeamMember', async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: 'u-1',
email: 'team@copilotkit.ai',
} as ReturnType<typeof prisma.user.findFirst> extends Promise<infer T> ? T : never);
vi.mocked(prisma.teamMember.findUnique).mockResolvedValue({
id: 'tm-1',
} as ReturnType<typeof prisma.teamMember.findUnique> extends Promise<infer T> ? T : never);

expect(await isTeamMember('discord-user-1')).toBe(true);
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: { externalId: 'discord-user-1', source: 'DISCORD' },
});
});

it('returns false when no User row exists', async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);

expect(await isTeamMember('unknown')).toBe(false);
});

it('returns false when the User has no email', async () => {
// `email` is non-nullable in the schema, so "no email" surfaces as the
// empty string — which the `!user?.email` guard must still reject.
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: 'u-1',
email: '',
} as ReturnType<typeof prisma.user.findFirst> extends Promise<infer T> ? T : never);

expect(await isTeamMember('no-email')).toBe(false);
expect(prisma.teamMember.findUnique).not.toHaveBeenCalled();
});
});
11 changes: 6 additions & 5 deletions apps/discord-bot/src/events/thread-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@ export async function handleThreadCreate(thread: ThreadChannel, newlyCreated: bo
const handler = new InboundHandler({ prisma, createJob: createJobFn });
const result = await handler.handle(inboundMessage);

// Post acknowledgment in the thread (Discord-specific UX)
await adapter.postSystemMessage(
{ id: result.ticketId, sourceId: thread.id, channel: parentId, source: adapter.platform },
`\uD83C\uDFAB Ticket ${result.displayId} created. Our AI assistant is reviewing your question...`,
);
// No acknowledgment post. This used to announce
// "\uD83C\uDFAB Ticket TKT-XXXXXXXX created..." in the thread, which leaked an
// internal identifier to the public server and spent a bot message
// saying nothing the reporter can act on. displayId is for the dashboard
// and team slash commands only — never for reporter-facing copy.
// The AI response itself is the only message the reporter needs.

console.log(`[Discord Bot] Created ticket ${result.displayId} for thread ${thread.id}`);
} catch (error) {
Expand Down
Loading