codex-flows/apps/discord-bridge/test/bridge.test.ts
2026-05-12 15:34:46 +00:00

2340 lines
65 KiB
TypeScript

import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "bun:test";
import type { JsonRpcNotification, JsonRpcRequest } from "@peezy.tech/codex-flows/rpc";
import type { v2 } from "@peezy.tech/codex-flows/generated";
import { DiscordCodexBridge, parseThreadStartIntent } from "../src/bridge.ts";
import type {
DiscordConsoleMessage,
DiscordConsoleOutput,
} from "../src/console-output.ts";
import { MemoryStateStore, emptyState } from "../src/state.ts";
import type {
CodexBridgeClient,
DiscordBridgeConfig,
DiscordBridgeTransport,
DiscordBridgeTransportHandlers,
DiscordInbound,
} from "../src/types.ts";
describe("DiscordCodexBridge", () => {
test("parses mention control text for resume and per-thread directories", () => {
expect(parseThreadStartIntent("resume codex-thread-123 --dir ~/project")).toEqual({
kind: "resume",
codexThreadId: "codex-thread-123",
cwd: path.join(os.homedir(), "project"),
});
expect(parseThreadStartIntent("--dir projects/demo inspect this")).toEqual({
kind: "new",
prompt: "inspect this",
cwd: path.join(os.homedir(), "projects/demo"),
});
expect(parseThreadStartIntent("resume")).toEqual({
kind: "invalid",
message: "Usage: @codex resume <codex-thread-id> [--dir path]",
});
});
test("starts a Discord thread from a mention and sends summaries only after chunks complete", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-mention-1",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
title: "Investigate release",
prompt: "What changed in this release?",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
expect(transport.createdThreads).toEqual([
{
channelId: "parent-channel",
name: "Investigate release",
sourceMessageId: "message-mention-1",
},
]);
expect(client.startThreadCalls).toHaveLength(1);
expect(client.setThreadNameCalls[0]).toEqual({
threadId: "codex-thread-1",
name: "[discord] Investigate release",
});
expect(client.startTurnCalls[0]?.input[0]).toEqual(
expect.objectContaining({
type: "text",
text: expect.stringContaining("What changed in this release?"),
}),
);
const messageCountAfterStart = transport.messages.length;
client.emitNotification({
method: "item/reasoning/summaryPartAdded",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "reasoning-1",
summaryIndex: 0,
},
});
client.emitNotification({
method: "item/reasoning/summaryTextDelta",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "reasoning-1",
summaryIndex: 0,
delta: "Checking changed files.",
},
});
client.emitNotification({
method: "item/reasoning/summaryTextDelta",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "reasoning-1",
summaryIndex: 0,
delta: " Reading test coverage.",
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(transport.messages).toHaveLength(messageCountAfterStart);
expect(
transport.updatedMessages.some((message) =>
message.text.includes("Checking changed files")
),
).toBe(false);
expect(
transport.messages.filter((message) =>
message.text.includes("Checking changed files")
),
).toHaveLength(0);
client.emitNotification({
method: "item/reasoning/summaryPartAdded",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "reasoning-1",
summaryIndex: 1,
},
});
await waitFor(() =>
transport.messages.some((message) =>
message.text === "Checking changed files. Reading test coverage."
)
);
client.emitNotification({
method: "item/reasoning/summaryTextDelta",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "reasoning-1",
summaryIndex: 1,
delta: "Inspecting implementation boundaries.",
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(
transport.messages.some((message) =>
message.text === "Inspecting implementation boundaries."
),
).toBe(false);
client.emitNotification({
method: "item/completed",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
item: {
id: "reasoning-1",
type: "reasoning",
summary: [
"Checking changed files. Reading test coverage.",
"Inspecting implementation boundaries.",
],
},
},
});
await waitFor(() =>
transport.messages.some((message) =>
message.text === "Inspecting implementation boundaries."
)
);
expect(
transport.updatedMessages.some((message) =>
message.text === "Inspecting implementation boundaries."
),
).toBe(false);
await waitFor(() => transport.typingCount >= 2);
client.emitNotification({
method: "item/agentMessage/delta",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "message-1",
delta: "The release changed the Discord bridge.",
},
});
client.emitNotification({
method: "item/completed",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
item: {
id: "message-1",
type: "agentMessage",
text: "The release changed the Discord bridge.",
phase: "final_answer",
memoryCitation: null,
},
},
});
client.emitNotification({
method: "turn/completed",
params: {
threadId: "codex-thread-1",
turn: { id: "turn-1" },
},
});
await waitFor(() =>
transport.messages.some((message) =>
message.text === "The release changed the Discord bridge."
)
);
expect(bridge.stateForTest().processedMessageIds).toContain(
"message-mention-1",
);
expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([
"summary",
"summary",
"final",
]);
await waitFor(() => transport.deletedMessages.length === 2);
expect(transport.deletedMessages.map((message) => message.text)).toEqual([
"Checking changed files. Reading test coverage.",
"Inspecting implementation boundaries.",
]);
expect(
transport.messages
.map((message) => message.text)
.filter((text) =>
[
"Checking changed files. Reading test coverage.",
"Inspecting implementation boundaries.",
"The release changed the Discord bridge.",
].includes(text)
),
).toEqual([
"The release changed the Discord bridge.",
]);
const typingCountAfterFinal = transport.typingCount;
await new Promise((resolve) => setTimeout(resolve, 30));
expect(transport.typingCount).toBe(typingCountAfterFinal);
await bridge.stop();
});
test("starts a thread from a bot DM by a global user outside allowed guild channels", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const bridge = new DiscordCodexBridge({
client,
transport,
store: new MemoryStateStore(),
config: testConfig({ allowedChannelIds: new Set(["guild-parent-channel"]) }),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-dm-1",
channelId: "bot-dm-channel",
author: { id: "user-1", name: "Ada", isBot: false },
title: "DM request",
prompt: "Handle this from DM.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
expect(transport.createdThreads).toEqual([
{
channelId: "bot-dm-channel",
name: "DM request",
sourceMessageId: "message-dm-1",
},
]);
expect(bridge.stateForTest().sessions[0]).toEqual(
expect.objectContaining({
discordThreadId: "discord-thread-1",
parentChannelId: "bot-dm-channel",
guildId: undefined,
}),
);
await bridge.stop();
});
test("can use commentary messages as progress and keep final output phase-aware", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const consoleOutput = new FakeConsoleOutput();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig({ progressMode: "commentary" }),
now: () => new Date("2026-05-11T00:00:00.000Z"),
consoleOutput,
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-mention-2",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
title: "Scan repo",
prompt: "Scan this repo.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
const messageCountAfterStart = transport.messages.length;
client.emitNotification({
method: "item/reasoning/summaryPartAdded",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "reasoning-1",
summaryIndex: 0,
},
});
client.emitNotification({
method: "item/reasoning/summaryTextDelta",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "reasoning-1",
summaryIndex: 0,
delta: "Reasoning summary should not post.",
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(transport.messages).toHaveLength(messageCountAfterStart);
client.emitNotification({
method: "item/agentMessage/delta",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "commentary-1",
delta: "I will scan the repo.",
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(
transport.messages.some((message) =>
message.text === "I will scan the repo."
),
).toBe(false);
client.emitNotification({
method: "item/completed",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
item: {
id: "commentary-1",
type: "agentMessage",
text: "I will scan the repo.",
phase: "commentary",
memoryCitation: null,
},
},
});
await waitFor(() =>
transport.messages.some((message) =>
message.text === "I will scan the repo."
)
);
expect(consoleOutput.messages).toEqual([
expect.objectContaining({
kind: "commentary",
text: "I will scan the repo.",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-1",
turnId: "turn-1",
title: "Scan repo",
}),
]);
client.emitNotification({
method: "item/agentMessage/delta",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "final-1",
delta: "Repo scan complete.",
},
});
client.emitNotification({
method: "item/completed",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
item: {
id: "final-1",
type: "agentMessage",
text: "Repo scan complete.",
phase: "final_answer",
memoryCitation: null,
},
},
});
client.emitNotification({
method: "turn/completed",
params: {
threadId: "codex-thread-1",
turn: {
id: "turn-1",
items: [
{
id: "commentary-1",
type: "agentMessage",
text: "I will scan the repo.",
phase: "commentary",
memoryCitation: null,
},
{
id: "final-1",
type: "agentMessage",
text: "Repo scan complete.",
phase: "final_answer",
memoryCitation: null,
},
],
},
},
});
await waitFor(() =>
transport.messages.some((message) => message.text === "Repo scan complete.")
);
expect(consoleOutput.messages).toEqual([
expect.objectContaining({
kind: "commentary",
text: "I will scan the repo.",
}),
expect.objectContaining({
kind: "final",
text: "Repo scan complete.",
turnId: "turn-1",
title: "Scan repo",
}),
]);
await waitFor(() => transport.deletedMessages.length === 1);
expect(transport.deletedMessages[0]?.text).toBe("I will scan the repo.");
expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([
"commentary",
"final",
]);
expect(
transport.messages.some((message) =>
message.text.includes("Reasoning summary should not post")
),
).toBe(false);
expect(
transport.messages.some((message) =>
message.text === "I will scan the repo."
),
).toBe(false);
expect(
transport.messages.filter((message) =>
message.text === "Repo scan complete."
),
).toHaveLength(1);
await bridge.stop();
});
test("grants mentioned users access only to the created Discord thread", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig({ allowedUserIds: new Set(["user-1", "user-admin"]) }),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-grant-start",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
prompt: "<@user-2> <@!user-3> Please investigate this repo.",
mentionedUserIds: ["user-2", "user-3", "user-1", "user-2"],
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
expect(transport.createdThreads).toEqual([
{
channelId: "parent-channel",
name: "Please investigate this repo.",
sourceMessageId: "message-grant-start",
},
]);
expect(transport.addedThreadMembers).toEqual([
{ channelId: "discord-thread-1", userIds: ["user-2", "user-3"] },
]);
const initialPrompt = inputText(client.startTurnCalls[0]?.input[0]);
expect(initialPrompt).toContain("Please investigate this repo.");
expect(initialPrompt).not.toContain("<@user-2>");
expect(initialPrompt).not.toContain("<@!user-3>");
expect(bridge.stateForTest().sessions[0]).toEqual(
expect.objectContaining({
ownerUserId: "user-1",
participantUserIds: ["user-2", "user-3"],
}),
);
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-from-grantee",
author: { id: "user-2", name: "Grace", isBot: false },
content: "Here is more context.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.steerTurnCalls.length === 1);
expect(client.steerTurnCalls[0]?.input[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining("Here is more context."),
}),
);
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-from-rando",
author: { id: "user-4", name: "Edsger", isBot: false },
content: "I should not reach Codex.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(client.steerTurnCalls).toHaveLength(1);
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-from-admin",
author: { id: "user-admin", name: "Admin", isBot: false },
content: "Admin context should reach Codex.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.steerTurnCalls.length === 2);
expect(client.steerTurnCalls[1]?.input[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining("Admin context should reach Codex."),
}),
);
transport.emit({
kind: "threadStart",
sourceMessageId: "message-grantee-start-denied",
channelId: "parent-channel",
author: { id: "user-2", name: "Grace", isBot: false },
prompt: "Start a second thread.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(transport.createdThreads).toHaveLength(1);
expect(client.startThreadCalls).toHaveLength(1);
await bridge.stop();
});
test("stores per-thread directories and pins a status message for new threads", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig({
allowedUserIds: new Set(["user-1", "user-admin"]),
approvalPolicy: "on-request",
sandbox: "workspace-write",
}),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-dir-start",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
prompt: "--dir ~/game-protocol-workspace Build the parser.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
const expectedCwd = path.join(os.homedir(), "game-protocol-workspace");
expect(client.startThreadCalls[0]?.cwd).toBe(expectedCwd);
expect(client.startTurnCalls[0]?.cwd).toBe(expectedCwd);
expect(inputText(client.startTurnCalls[0]?.input[0])).toContain(
"Build the parser.",
);
expect(inputText(client.startTurnCalls[0]?.input[0])).not.toContain("--dir");
expect(bridge.stateForTest().sessions[0]).toEqual(
expect.objectContaining({
cwd: expectedCwd,
mode: "new",
statusMessageId: "message-out-1",
}),
);
expect(transport.pinnedMessages).toEqual([
{ channelId: "discord-thread-1", messageId: "message-out-1" },
]);
const statusText = transport.messages.find((message) =>
message.id === "message-out-1"
)?.text ?? "";
expect(statusText).toContain("**Codex Discord Bridge**");
expect(statusText).toContain(`Dir: \`${expectedCwd}\``);
expect(statusText).toContain("Global admins: <@user-1>, <@user-admin>");
expect(statusText).toContain("Permissions: approval `on-request`");
client.emitNotification({
method: "item/completed",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
item: {
id: "message-final",
type: "agentMessage",
text: "First turn done.",
phase: "final_answer",
memoryCitation: null,
},
},
});
client.emitNotification({
method: "turn/completed",
params: {
threadId: "codex-thread-1",
turn: { id: "turn-1" },
},
});
await waitFor(() => bridge.stateForTest().queue.length === 0);
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-follow-up",
author: { id: "user-1", name: "Ada", isBot: false },
content: "Continue in the same directory.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 2);
expect(client.startTurnCalls[1]?.cwd).toBe(expectedCwd);
await bridge.stop();
});
test("resumes arbitrary Codex threads without prompting and replays the last final message", async () => {
const client = new FakeCodexClient();
const resumedThreadId = "019e1951-5355-78d2-8162-3b2b11dfc4a5";
client.threadTurns.set(resumedThreadId, [
{
id: "turn-old-1",
status: "completed",
items: [
{
type: "agentMessage",
id: "old-final",
text: "Earlier answer.",
phase: "final_answer",
memoryCitation: null,
},
],
} as unknown as v2.Turn,
{
id: "turn-old-2",
status: "completed",
items: [
{
type: "agentMessage",
id: "latest-commentary",
text: "This is commentary.",
phase: "commentary",
memoryCitation: null,
},
{
type: "agentMessage",
id: "latest-final",
text: "Latest final answer.",
phase: "final_answer",
memoryCitation: null,
},
],
} as unknown as v2.Turn,
]);
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-resume-start",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
prompt: `resume ${resumedThreadId} --dir ~/game-protocol-workspace`,
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() =>
transport.messages.some((message) => message.text === "Latest final answer.")
);
const expectedCwd = path.join(os.homedir(), "game-protocol-workspace");
expect(client.resumeThreadCalls).toHaveLength(1);
expect(client.resumeThreadCalls[0]).toEqual(
expect.objectContaining({
threadId: resumedThreadId,
cwd: expectedCwd,
}),
);
expect(client.startThreadCalls).toHaveLength(0);
expect(client.startTurnCalls).toHaveLength(0);
expect(client.setThreadNameCalls).toHaveLength(0);
expect(bridge.stateForTest().sessions[0]).toEqual(
expect.objectContaining({
codexThreadId: resumedThreadId,
cwd: expectedCwd,
mode: "resumed",
statusMessageId: "message-out-1",
}),
);
expect(transport.pinnedMessages).toEqual([
{ channelId: "discord-thread-1", messageId: "message-out-1" },
]);
expect(transport.messages[0]?.text).toContain("Mode: `resumed`");
expect(transport.messages[0]?.text).toContain(`Dir: \`${expectedCwd}\``);
expect(transport.messages.map((message) => message.text)).toContain(
"Latest final answer.",
);
await bridge.stop();
});
test("ignores historical progress notifications after resume replay", async () => {
const client = new FakeCodexClient();
const resumedThreadId = "019e1951-5355-78d2-8162-3b2b11dfc4a5";
const completedTurn = {
id: "turn-history-1",
status: "completed",
items: [
{
id: "latest-final",
type: "agentMessage",
text: "Latest final answer.",
phase: "final_answer",
memoryCitation: null,
},
],
} as unknown as v2.Turn;
client.threadTurns.set(resumedThreadId, [completedTurn]);
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig({ progressMode: "commentary" }),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-resume-history",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
prompt: `resume ${resumedThreadId}`,
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() =>
bridge.stateForTest().processedMessageIds.includes("message-resume-history")
);
const messagesAfterResume = transport.messages.map((message) => message.text);
expect(
messagesAfterResume.filter((message) => message === "Latest final answer."),
).toHaveLength(1);
client.emitNotification({
method: "item/completed",
params: {
threadId: resumedThreadId,
turnId: "turn-history-1",
itemId: "historical-commentary",
item: {
id: "historical-commentary",
type: "agentMessage",
text: "Historical commentary.",
phase: "commentary",
memoryCitation: null,
},
},
} as JsonRpcNotification);
client.emitNotification({
method: "turn/completed",
params: {
threadId: resumedThreadId,
turnId: "turn-history-1",
turn: completedTurn,
},
} as JsonRpcNotification);
await sleep(50);
expect(transport.messages.map((message) => message.text)).toEqual(
messagesAfterResume,
);
expect(transport.deletedMessages).toEqual([]);
expect(bridge.stateForTest().activeTurns).toEqual([]);
await bridge.stop();
});
test("cleans stale historical progress after resume restart", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
transport.messages.push(
{
channelId: "discord-thread-1",
id: "message-stale-commentary-1",
text: "Stale commentary 1.",
},
{
channelId: "discord-thread-1",
id: "message-stale-commentary-2",
text: "Stale commentary 2.",
},
);
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-1",
parentChannelId: "parent-channel",
sourceMessageId: "message-resume-start",
codexThreadId: "codex-thread-resumed",
title: "Resumed thread",
createdAt: "2026-05-11T00:00:00.000Z",
mode: "resumed",
statusMessageId: "message-status-1",
},
],
activeTurns: [
{
turnId: "turn-history-1",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-resumed",
origin: "external",
observedAt: "2026-05-11T00:00:00.000Z",
},
],
deliveries: [
{
discordMessageId: "resume:message-resume-start:turn-history-1",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-resumed",
turnId: "turn-history-1",
kind: "final",
outboundMessageIds: ["message-final-1"],
deliveredAt: "2026-05-11T00:00:00.000Z",
},
{
discordMessageId: "external:turn-history-1",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-resumed",
turnId: "turn-history-1",
kind: "commentary",
outboundMessageIds: [
"message-stale-commentary-1",
"message-stale-commentary-2",
],
deliveredAt: "2026-05-11T00:00:00.000Z",
},
],
});
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
await waitFor(() => transport.deletedMessages.length === 2);
expect(transport.deletedMessages.map((message) => message.messageId)).toEqual([
"message-stale-commentary-1",
"message-stale-commentary-2",
]);
expect(bridge.stateForTest().activeTurns).toEqual([]);
expect(
bridge.stateForTest().deliveries.find(
(delivery) => delivery.kind === "commentary",
)?.outboundMessageIds,
).toEqual([]);
await bridge.stop();
});
test("resume without dir uses the resumed Codex thread cwd", async () => {
const client = new FakeCodexClient();
const resumedThreadId = "019e1951-5355-78d2-8162-3b2b11dfc4a5";
const threadCwd = "/home/peezy/original-thread-workspace";
client.threadCwds.set(resumedThreadId, threadCwd);
client.threadTurns.set(resumedThreadId, [
{
id: "turn-old-1",
status: "completed",
items: [
{
type: "agentMessage",
id: "latest-final",
text: "Original cwd answer.",
phase: "final_answer",
memoryCitation: null,
},
],
} as unknown as v2.Turn,
]);
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig({ cwd: "/home/peezy/game-protocol-workspace" }),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-resume-no-dir",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
prompt: `resume ${resumedThreadId}`,
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() =>
transport.messages.some((message) => message.text === "Original cwd answer.")
);
expect(client.resumeThreadCalls[0]).toEqual(
expect.objectContaining({
threadId: resumedThreadId,
cwd: null,
}),
);
expect(bridge.stateForTest().sessions[0]).toEqual(
expect.objectContaining({
codexThreadId: resumedThreadId,
cwd: threadCwd,
mode: "resumed",
}),
);
expect(transport.messages[0]?.text).toContain(`Dir: \`${threadCwd}\``);
await bridge.stop();
});
test("updates pinned status with goal, plan, and running command metadata", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
let now = new Date("2026-05-11T00:00:00.000Z");
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => now,
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-status-start",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
prompt: "Inspect status updates.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
client.emitNotification({
method: "thread/goal/updated",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
goal: {
threadId: "codex-thread-1",
objective: "Ship the Discord bridge status surface",
status: "active",
tokenBudget: null,
tokensUsed: 10,
timeUsedSeconds: 2,
createdAt: 0,
updatedAt: 0,
},
},
});
client.emitNotification({
method: "turn/plan/updated",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
explanation: null,
plan: [
{ step: "Inspect current bridge", status: "completed" },
{ step: "Implement pinned status", status: "inProgress" },
],
},
});
client.emitNotification({
method: "item/started",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
item: {
type: "commandExecution",
id: "command-1",
command: "bun test test/*.test.ts",
cwd: "/workspace",
processId: "process-1",
source: "agent",
status: "inProgress",
commandActions: [],
aggregatedOutput: null,
exitCode: null,
durationMs: null,
},
},
});
await waitFor(() => {
const text = statusMessageText(transport);
return text.includes("Ship the Discord bridge status surface") &&
text.includes("Implement pinned status");
});
let statusText = statusMessageText(transport);
expect(statusText).toContain("Goal: `active` Ship the Discord bridge status surface");
expect(statusText).toContain("- `completed` Inspect current bridge");
expect(statusText).toContain("- `inProgress` Implement pinned status");
expect(statusText).toContain("**Running Commands**\nnone");
expect(statusText).not.toContain("bun test test/*.test.ts");
now = new Date("2026-05-11T00:00:05.000Z");
client.emitNotification({
method: "item/commandExecution/outputDelta",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
itemId: "command-1",
delta: "test output",
},
});
await waitFor(() =>
statusMessageText(transport).includes("bun test test/*.test.ts")
);
statusText = statusMessageText(transport);
expect(statusText).toContain("- `bun test test/*.test.ts`");
client.emitNotification({
method: "item/completed",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
item: {
type: "commandExecution",
id: "command-1",
command: "bun test test/*.test.ts",
cwd: "/workspace",
processId: "process-1",
source: "agent",
status: "completed",
commandActions: [],
aggregatedOutput: "",
exitCode: 0,
durationMs: 10,
},
},
});
await waitFor(() => !statusMessageText(transport).includes("bun test"));
statusText = statusMessageText(transport);
expect(statusText).toContain("**Running Commands**\nnone");
const messageCountBeforeActivity = transport.messages.length;
client.emitNotification({
method: "item/started",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
item: {
type: "fileChange",
id: "patch-1",
changes: [
{ type: "add", path: "/workspace/src/worker.ts", content: "test" },
{ type: "update", path: "/workspace/package.json", content: "test" },
],
status: "inProgress",
},
},
});
await waitFor(() =>
statusMessageText(transport).includes("files: 2 file changes")
);
statusText = statusMessageText(transport);
expect(statusText).toContain("**Activity**");
expect(statusText).toContain("- `inProgress` files: 2 file changes");
expect(transport.messages).toHaveLength(messageCountBeforeActivity);
client.emitNotification({
method: "item/completed",
params: {
threadId: "codex-thread-1",
turnId: "turn-1",
item: {
type: "mcpToolCall",
id: "mcp-1",
server: "github",
tool: "search",
status: "completed",
arguments: {},
result: null,
error: null,
durationMs: 42,
},
},
});
await waitFor(() =>
statusMessageText(transport).includes("mcp: github.search")
);
statusText = statusMessageText(transport);
expect(statusText).toContain("- `completed` mcp: github.search");
expect(transport.messages).toHaveLength(messageCountBeforeActivity);
await bridge.stop();
});
test("mirrors external turns on managed threads into Discord", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-watch-start",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
title: "Watch external work",
prompt: "",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => bridge.stateForTest().sessions.length === 1);
expect(client.startTurnCalls).toHaveLength(0);
client.emitNotification({
method: "turn/started",
params: {
threadId: "codex-thread-1",
turn: {
id: "external-turn-1",
status: "inProgress",
items: [],
startedAt: 1778457600,
},
},
});
await waitFor(() =>
statusMessageText(transport).includes("origin `external`")
);
expect(bridge.stateForTest().activeTurns[0]).toEqual(
expect.objectContaining({
turnId: "external-turn-1",
origin: "external",
}),
);
expect(transport.typingCount).toBeGreaterThan(0);
client.emitNotification({
method: "item/reasoning/summaryPartAdded",
params: {
threadId: "codex-thread-1",
turnId: "external-turn-1",
itemId: "reasoning-1",
summaryIndex: 0,
},
});
client.emitNotification({
method: "item/reasoning/summaryTextDelta",
params: {
threadId: "codex-thread-1",
turnId: "external-turn-1",
itemId: "reasoning-1",
summaryIndex: 0,
delta: "External source is working.",
},
});
client.emitNotification({
method: "item/reasoning/summaryPartAdded",
params: {
threadId: "codex-thread-1",
turnId: "external-turn-1",
itemId: "reasoning-1",
summaryIndex: 1,
},
});
await waitFor(() =>
transport.messages.some((message) =>
message.text === "External source is working."
)
);
client.emitNotification({
method: "item/completed",
params: {
threadId: "codex-thread-1",
turnId: "external-turn-1",
item: {
id: "message-final",
type: "agentMessage",
text: "External final answer.",
phase: "final_answer",
memoryCitation: null,
},
},
});
client.emitNotification({
method: "turn/completed",
params: {
threadId: "codex-thread-1",
turn: {
id: "external-turn-1",
status: "completed",
items: [],
},
},
});
await waitFor(() =>
transport.messages.some((message) => message.text === "External final answer.")
);
await waitFor(() => transport.deletedMessages.length === 1);
expect(transport.deletedMessages[0]?.text).toBe("External source is working.");
expect(bridge.stateForTest().activeTurns).toEqual([]);
expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([
"summary",
"final",
]);
await bridge.stop();
});
test("steers Discord messages into externally started active turns", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "threadStart",
sourceMessageId: "message-cross-source-start",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
title: "Cross source steering",
prompt: "",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => bridge.stateForTest().sessions.length === 1);
client.emitNotification({
method: "turn/started",
params: {
threadId: "codex-thread-1",
turn: {
id: "external-turn-1",
status: "inProgress",
items: [],
},
},
});
await waitFor(() => bridge.stateForTest().activeTurns.length === 1);
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-steer-external",
author: { id: "user-1", name: "Ada", isBot: false },
content: "Please include the Discord context.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.steerTurnCalls.length === 1);
expect(client.startTurnCalls).toHaveLength(0);
expect(client.steerTurnCalls[0]).toEqual(
expect.objectContaining({
threadId: "codex-thread-1",
expectedTurnId: "external-turn-1",
}),
);
expect(inputText(client.steerTurnCalls[0]?.input[0])).toContain(
"Please include the Discord context.",
);
expect(bridge.stateForTest().processedMessageIds).toContain(
"message-steer-external",
);
client.emitNotification({
method: "turn/completed",
params: {
threadId: "codex-thread-1",
turn: {
id: "external-turn-1",
status: "completed",
items: [
{
id: "message-final",
type: "agentMessage",
text: "External turn completed.",
phase: "final_answer",
memoryCitation: null,
},
],
},
},
});
await waitFor(() => bridge.stateForTest().activeTurns.length === 0);
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-new-turn",
author: { id: "user-1", name: "Ada", isBot: false },
content: "Start a new Discord turn now.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
expect(client.steerTurnCalls).toHaveLength(1);
await bridge.stop();
});
test("recovers persisted external active turns and edits the status message", async () => {
const client = new FakeCodexClient();
client.threadTurns.set("codex-thread-existing", [
{
id: "external-turn-recovered",
status: "inProgress",
items: [],
} as unknown as v2.Turn,
]);
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-1",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-existing",
title: "Existing thread",
createdAt: "2026-05-11T00:00:00.000Z",
statusMessageId: "message-status-1",
},
],
activeTurns: [
{
turnId: "external-turn-recovered",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-existing",
origin: "external",
observedAt: "2026-05-11T00:00:00.000Z",
},
],
});
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig({ reconcileIntervalMs: 10 }),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
await waitFor(() =>
transport.updatedMessages.some((message) =>
message.messageId === "message-status-1" &&
message.text.includes("origin `external`")
)
);
expect(transport.pinnedMessages).toContainEqual({
channelId: "discord-thread-1",
messageId: "message-status-1",
});
expect(transport.typingCount).toBeGreaterThan(0);
client.threadTurns.set("codex-thread-existing", [
{
id: "external-turn-recovered",
status: "completed",
items: [
{
id: "message-final",
type: "agentMessage",
text: "Recovered external final.",
phase: "final_answer",
memoryCitation: null,
},
],
} as unknown as v2.Turn,
]);
await waitFor(() =>
transport.messages.some((message) => message.text === "Recovered external final.")
);
expect(bridge.stateForTest().activeTurns).toEqual([]);
await bridge.stop();
});
test("clear deletes inactive managed threads and preserves running threads", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const replies: string[] = [];
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-idle",
parentChannelId: "parent-channel",
sourceMessageId: "message-idle-start",
codexThreadId: "codex-thread-idle",
title: "Idle",
createdAt: "2026-05-11T00:00:00.000Z",
},
{
discordThreadId: "discord-thread-active",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-active",
title: "Active",
createdAt: "2026-05-11T00:00:00.000Z",
},
{
discordThreadId: "discord-thread-pending",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-pending",
title: "Pending",
createdAt: "2026-05-11T00:00:00.000Z",
},
{
discordThreadId: "discord-thread-failed",
parentChannelId: "parent-channel",
sourceMessageId: "message-failed-start",
codexThreadId: "codex-thread-failed",
title: "Failed",
createdAt: "2026-05-11T00:00:00.000Z",
},
],
activeTurns: [
{
turnId: "turn-active",
discordThreadId: "discord-thread-active",
codexThreadId: "codex-thread-active",
origin: "external",
observedAt: "2026-05-11T00:00:00.000Z",
},
],
queue: [
{
id: "queue-pending",
status: "pending",
discordMessageId: "message-pending",
discordThreadId: "discord-thread-pending",
codexThreadId: "codex-thread-pending",
authorId: "user-1",
authorName: "Ada",
content: "Pending work.",
createdAt: "2026-05-11T00:00:00.000Z",
receivedAt: "2026-05-11T00:00:00.000Z",
attempts: 0,
},
{
id: "queue-failed",
status: "failed",
discordMessageId: "message-failed",
discordThreadId: "discord-thread-failed",
codexThreadId: "codex-thread-failed",
authorId: "user-1",
authorName: "Ada",
content: "Failed work.",
createdAt: "2026-05-11T00:00:00.000Z",
receivedAt: "2026-05-11T00:00:00.000Z",
attempts: 3,
},
],
deliveries: [
{
discordMessageId: "message-idle",
discordThreadId: "discord-thread-idle",
codexThreadId: "codex-thread-idle",
kind: "final",
outboundMessageIds: ["message-out-idle"],
deliveredAt: "2026-05-11T00:00:00.000Z",
},
],
});
transport.messages.push(
{
channelId: "parent-channel",
id: "message-idle-start",
text: "<@bot> scan idle",
},
{
channelId: "parent-channel",
id: "message-failed-start",
text: "<@bot> scan failed",
},
);
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "clear",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
createdAt: "2026-05-11T00:00:00.000Z",
reply: async (text) => {
replies.push(text);
},
});
await waitFor(() => replies.length === 1);
expect(transport.deletedThreads).toEqual([
"discord-thread-idle",
"discord-thread-failed",
]);
expect(
transport.deletedMessages.map(({ channelId, messageId }) => ({
channelId,
messageId,
})),
).toEqual([
{ channelId: "parent-channel", messageId: "message-idle-start" },
{ channelId: "parent-channel", messageId: "message-failed-start" },
]);
expect(replies[0]).toBe(
"Deleted 2 inactive Discord threads. Left 2 running threads alone.",
);
expect(bridge.stateForTest().sessions.map((session) => session.discordThreadId))
.toEqual(["discord-thread-active", "discord-thread-pending"]);
expect(bridge.stateForTest().queue.map((item) => item.discordThreadId))
.toEqual(["discord-thread-pending"]);
expect(bridge.stateForTest().deliveries).toEqual([]);
await bridge.stop();
});
test("clear only deletes inactive managed threads in the command guild", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const replies: string[] = [];
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-guild-a-idle",
parentChannelId: "parent-channel-a",
guildId: "guild-a",
codexThreadId: "codex-thread-guild-a-idle",
title: "Guild A idle",
createdAt: "2026-05-11T00:00:00.000Z",
},
{
discordThreadId: "discord-thread-guild-a-active",
parentChannelId: "parent-channel-a",
guildId: "guild-a",
codexThreadId: "codex-thread-guild-a-active",
title: "Guild A active",
createdAt: "2026-05-11T00:00:00.000Z",
},
{
discordThreadId: "discord-thread-guild-b-idle",
parentChannelId: "parent-channel-b",
guildId: "guild-b",
codexThreadId: "codex-thread-guild-b-idle",
title: "Guild B idle",
createdAt: "2026-05-11T00:00:00.000Z",
},
],
activeTurns: [
{
turnId: "turn-guild-a-active",
discordThreadId: "discord-thread-guild-a-active",
codexThreadId: "codex-thread-guild-a-active",
origin: "external",
observedAt: "2026-05-11T00:00:00.000Z",
},
],
});
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "clear",
channelId: "parent-channel-a",
guildId: "guild-a",
author: { id: "user-1", name: "Ada", isBot: false },
createdAt: "2026-05-11T00:00:00.000Z",
reply: async (text) => {
replies.push(text);
},
});
await waitFor(() => replies.length === 1);
expect(transport.deletedThreads).toEqual(["discord-thread-guild-a-idle"]);
expect(replies[0]).toBe(
"Deleted 1 inactive Discord thread. Left 1 running thread alone.",
);
expect(bridge.stateForTest().sessions.map((session) => session.discordThreadId))
.toEqual(["discord-thread-guild-a-active", "discord-thread-guild-b-idle"]);
await bridge.stop();
});
test("clear from a bot DM by a global user deletes inactive threads across guilds", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const replies: string[] = [];
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-guild-a-idle",
parentChannelId: "parent-channel-a",
guildId: "guild-a",
codexThreadId: "codex-thread-guild-a-idle",
title: "Guild A idle",
createdAt: "2026-05-11T00:00:00.000Z",
},
{
discordThreadId: "discord-thread-guild-b-idle",
parentChannelId: "parent-channel-b",
guildId: "guild-b",
codexThreadId: "codex-thread-guild-b-idle",
title: "Guild B idle",
createdAt: "2026-05-11T00:00:00.000Z",
},
{
discordThreadId: "discord-thread-guild-b-active",
parentChannelId: "parent-channel-b",
guildId: "guild-b",
codexThreadId: "codex-thread-guild-b-active",
title: "Guild B active",
createdAt: "2026-05-11T00:00:00.000Z",
},
],
activeTurns: [
{
turnId: "turn-guild-b-active",
discordThreadId: "discord-thread-guild-b-active",
codexThreadId: "codex-thread-guild-b-active",
origin: "external",
observedAt: "2026-05-11T00:00:00.000Z",
},
],
});
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "clear",
channelId: "bot-dm-channel",
author: { id: "user-1", name: "Ada", isBot: false },
createdAt: "2026-05-11T00:00:00.000Z",
reply: async (text) => {
replies.push(text);
},
});
await waitFor(() => replies.length === 1);
expect(transport.deletedThreads).toEqual([
"discord-thread-guild-a-idle",
"discord-thread-guild-b-idle",
]);
expect(replies[0]).toBe(
"Deleted 2 inactive Discord threads. Left 1 running thread alone.",
);
expect(bridge.stateForTest().sessions.map((session) => session.discordThreadId))
.toEqual(["discord-thread-guild-b-active"]);
await bridge.stop();
});
test("clear is restricted to global allowed users", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const replies: string[] = [];
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-idle",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-idle",
title: "Idle",
createdAt: "2026-05-11T00:00:00.000Z",
},
],
});
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "clear",
channelId: "parent-channel",
author: { id: "user-2", name: "Grace", isBot: false },
createdAt: "2026-05-11T00:00:00.000Z",
reply: async (text) => {
replies.push(text);
},
});
await waitFor(() => replies.length === 1);
expect(transport.deletedThreads).toEqual([]);
expect(replies[0]).toBe(
"Only globally allowed Discord users can clear bridge threads.",
);
expect(bridge.stateForTest().sessions).toHaveLength(1);
await bridge.stop();
});
test("continues existing managed Discord threads and dedupes messages", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-1",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-existing",
title: "Existing thread",
createdAt: "2026-05-11T00:00:00.000Z",
},
],
});
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-1",
author: { id: "user-1", name: "Ada", isBot: false },
content: "Continue here.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
expect(client.startThreadCalls).toHaveLength(0);
expect(client.startTurnCalls[0]?.threadId).toBe("codex-thread-existing");
expect(client.startTurnCalls[0]?.input[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining("Message: message-1"),
}),
);
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-1",
author: { id: "user-1", name: "Ada", isBot: false },
content: "Continue here.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(client.startTurnCalls).toHaveLength(1);
await bridge.stop();
});
test("dedupes replayed mention starts before creating another thread", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore();
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
const mentionStart: DiscordInbound = {
kind: "threadStart",
sourceMessageId: "mention-replay-1",
channelId: "parent-channel",
author: { id: "user-1", name: "Ada", isBot: false },
prompt: "Please inspect this once.",
createdAt: "2026-05-11T00:00:00.000Z",
};
await bridge.start();
transport.emit(mentionStart);
transport.emit(mentionStart);
await waitFor(() => client.startTurnCalls.length === 1);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(transport.createdThreads).toHaveLength(1);
expect(client.startThreadCalls).toHaveLength(1);
expect(client.startTurnCalls).toHaveLength(1);
await bridge.stop();
});
test("steers an active turn in one Discord thread without blocking another thread", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-1",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-1",
title: "Thread one",
createdAt: "2026-05-11T00:00:00.000Z",
},
{
discordThreadId: "discord-thread-2",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-2",
title: "Thread two",
createdAt: "2026-05-11T00:00:00.000Z",
},
],
});
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-a1",
author: { id: "user-1", name: "Ada", isBot: false },
content: "First same-thread message.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
await waitFor(() =>
bridge.stateForTest().queue.some((item) =>
item.discordMessageId === "message-a1" &&
item.status === "processing" &&
item.turnId === "turn-1"
)
);
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-a2",
author: { id: "user-1", name: "Ada", isBot: false },
content: "Second same-thread message.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(client.startTurnCalls).toHaveLength(1);
expect(client.steerTurnCalls).toHaveLength(1);
expect(client.steerTurnCalls[0]).toEqual(
expect.objectContaining({
threadId: "codex-thread-1",
expectedTurnId: "turn-1",
}),
);
expect(client.steerTurnCalls[0]?.input[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining("Second same-thread message."),
}),
);
expect(bridge.stateForTest().processedMessageIds).toContain("message-a2");
transport.emit({
kind: "message",
channelId: "discord-thread-2",
messageId: "message-b1",
author: { id: "user-1", name: "Ada", isBot: false },
content: "Other thread message.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 2);
expect(client.startTurnCalls.map((call) => call.threadId)).toEqual([
"codex-thread-1",
"codex-thread-2",
]);
await waitFor(() =>
bridge.stateForTest().queue.filter((item) => item.status === "processing")
.length === 2
);
expect(
bridge.stateForTest().queue.filter((item) => item.status === "pending")
.map((item) => item.discordMessageId),
).toEqual([]);
await bridge.stop();
});
test("reconciles a completed persisted turn on startup", async () => {
const client = new FakeCodexClient();
client.threadTurns.set("codex-thread-existing", [
{
id: "turn-recovered",
status: "completed",
items: [
{
type: "agentMessage",
id: "message-final",
text: "Recovered final answer.",
phase: "final_answer",
memoryCitation: null,
},
],
} as unknown as v2.Turn,
]);
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-1",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-existing",
title: "Existing thread",
createdAt: "2026-05-11T00:00:00.000Z",
},
],
queue: [
{
id: "queue-1",
status: "processing",
discordMessageId: "message-1",
discordThreadId: "discord-thread-1",
codexThreadId: "codex-thread-existing",
authorId: "user-1",
authorName: "Ada",
content: "Recover this.",
createdAt: "2026-05-11T00:00:00.000Z",
receivedAt: "2026-05-11T00:00:00.000Z",
attempts: 0,
turnId: "turn-recovered",
},
],
});
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig(),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
await waitFor(() =>
transport.messages.some((message) => message.text === "Recovered final answer.")
);
expect(bridge.stateForTest().queue).toEqual([]);
expect(bridge.stateForTest().processedMessageIds).toContain("message-1");
expect(bridge.stateForTest().deliveries.map((delivery) => delivery.kind)).toEqual([
"final",
]);
await bridge.stop();
});
test("reconciles an active turn when the completion notification is missed", async () => {
const client = new FakeCodexClient();
const transport = new FakeDiscordTransport();
const store = new MemoryStateStore({
...emptyState(),
sessions: [
{
discordThreadId: "discord-thread-1",
parentChannelId: "parent-channel",
codexThreadId: "codex-thread-existing",
title: "Existing thread",
createdAt: "2026-05-11T00:00:00.000Z",
},
],
});
const bridge = new DiscordCodexBridge({
client,
transport,
store,
config: testConfig({ reconcileIntervalMs: 10 }),
now: () => new Date("2026-05-11T00:00:00.000Z"),
});
await bridge.start();
transport.emit({
kind: "message",
channelId: "discord-thread-1",
messageId: "message-1",
author: { id: "user-1", name: "Ada", isBot: false },
content: "Complete without a notification.",
createdAt: "2026-05-11T00:00:00.000Z",
});
await waitFor(() => client.startTurnCalls.length === 1);
client.threadTurns.set("codex-thread-existing", [
{
id: "turn-1",
status: "completed",
items: [
{
type: "agentMessage",
id: "message-final",
text: "Recovered by polling.",
phase: "final_answer",
memoryCitation: null,
},
],
} as unknown as v2.Turn,
]);
await waitFor(() =>
transport.messages.some((message) => message.text === "Recovered by polling.")
);
expect(bridge.stateForTest().queue).toEqual([]);
expect(bridge.stateForTest().processedMessageIds).toContain("message-1");
await bridge.stop();
});
});
function testConfig(
overrides: Partial<DiscordBridgeConfig> = {},
): DiscordBridgeConfig {
return {
allowedUserIds: new Set(["user-1"]),
allowedChannelIds: new Set(["parent-channel"]),
statePath: "/tmp/codex-discord-bridge-test/state.json",
cwd: "/workspace",
summary: "auto",
progressMode: "summary",
typingIntervalMs: 10,
...overrides,
};
}
class FakeCodexClient implements CodexBridgeClient {
startThreadCalls: v2.ThreadStartParams[] = [];
resumeThreadCalls: v2.ThreadResumeParams[] = [];
setThreadNameCalls: v2.ThreadSetNameParams[] = [];
startTurnCalls: v2.TurnStartParams[] = [];
steerTurnCalls: v2.TurnSteerParams[] = [];
readThreadCalls: v2.ThreadReadParams[] = [];
getThreadGoalCalls: v2.ThreadGoalGetParams[] = [];
threadTurns = new Map<string, v2.Turn[]>();
threadCwds = new Map<string, string>();
threadGoals = new Map<string, v2.ThreadGoal | null>();
blockStartTurn = false;
#startTurnResolvers: Array<() => void> = [];
#notificationListeners: Array<(message: JsonRpcNotification) => void> = [];
#requestListeners: Array<(message: JsonRpcRequest) => void> = [];
async connect(): Promise<void> {}
close(): void {}
on(
event: "notification",
listener: (message: JsonRpcNotification) => void,
): unknown;
on(
event: "request",
listener: (message: JsonRpcRequest) => void,
): unknown;
on(
event: "notification" | "request",
listener:
| ((message: JsonRpcNotification) => void)
| ((message: JsonRpcRequest) => void),
): unknown {
if (event === "notification") {
this.#notificationListeners.push(
listener as (message: JsonRpcNotification) => void,
);
return;
}
this.#requestListeners.push(listener as (message: JsonRpcRequest) => void);
}
async startThread(params: v2.ThreadStartParams): Promise<v2.ThreadStartResponse> {
this.startThreadCalls.push(params);
return {
thread: { id: `codex-thread-${this.startThreadCalls.length}` },
} as v2.ThreadStartResponse;
}
async resumeThread(params: v2.ThreadResumeParams): Promise<v2.ThreadResumeResponse> {
this.resumeThreadCalls.push(params);
const cwd = params.cwd ?? this.threadCwds.get(params.threadId) ?? "/workspace";
return {
cwd,
thread: {
id: params.threadId,
cwd,
turns: this.threadTurns.get(params.threadId) ?? [],
},
} as unknown as v2.ThreadResumeResponse;
}
async setThreadName(
params: v2.ThreadSetNameParams,
): Promise<v2.ThreadSetNameResponse> {
this.setThreadNameCalls.push(params);
return {};
}
async startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse> {
this.startTurnCalls.push(params);
const turnNumber = this.startTurnCalls.length;
if (this.blockStartTurn) {
await new Promise<void>((resolve) => {
this.#startTurnResolvers.push(resolve);
});
}
return {
turn: { id: `turn-${turnNumber}` },
} as v2.TurnStartResponse;
}
async steerTurn(params: v2.TurnSteerParams): Promise<v2.TurnSteerResponse> {
this.steerTurnCalls.push(params);
return { turnId: params.expectedTurnId };
}
async readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse> {
this.readThreadCalls.push(params);
return {
thread: { turns: this.threadTurns.get(params.threadId) ?? [] },
} as unknown as v2.ThreadReadResponse;
}
async getThreadGoal(
params: v2.ThreadGoalGetParams,
): Promise<v2.ThreadGoalGetResponse> {
this.getThreadGoalCalls.push(params);
return {
goal: this.threadGoals.get(params.threadId) ?? null,
};
}
respondError(): void {}
resolveAllStartTurns(): void {
for (const resolve of this.#startTurnResolvers.splice(0)) {
resolve();
}
}
emitNotification(message: JsonRpcNotification): void {
for (const listener of this.#notificationListeners) {
listener(message);
}
}
}
class FakeDiscordTransport implements DiscordBridgeTransport {
handlers: DiscordBridgeTransportHandlers | undefined;
createdThreads: Array<{
channelId: string;
name: string;
sourceMessageId?: string;
}> = [];
messages: Array<{ channelId: string; id: string; text: string }> = [];
updatedMessages: Array<{
channelId: string;
messageId: string;
text: string;
}> = [];
deletedMessages: Array<{
channelId: string;
messageId: string;
text: string;
}> = [];
deletedThreads: string[] = [];
addedThreadMembers: Array<{ channelId: string; userIds: string[] }> = [];
pinnedMessages: Array<{ channelId: string; messageId: string }> = [];
typingCount = 0;
async start(handlers: DiscordBridgeTransportHandlers): Promise<void> {
this.handlers = handlers;
}
async stop(): Promise<void> {}
async registerCommands(): Promise<void> {}
async createThread(
channelId: string,
name: string,
sourceMessageId?: string,
): Promise<string> {
this.createdThreads.push({ channelId, name, sourceMessageId });
return `discord-thread-${this.createdThreads.length}`;
}
async sendMessage(channelId: string, text: string): Promise<string[]> {
const id = `message-out-${this.messages.length + 1}`;
this.messages.push({ channelId, id, text });
return [id];
}
async updateMessage(
channelId: string,
messageId: string,
text: string,
): Promise<void> {
this.updatedMessages.push({ channelId, messageId, text });
const message = this.messages.find((candidate) => candidate.id === messageId);
if (message) {
message.text = text;
}
}
async deleteMessage(channelId: string, messageId: string): Promise<void> {
const message = this.messages.find((candidate) => candidate.id === messageId);
if (message) {
this.deletedMessages.push({ channelId, messageId, text: message.text });
this.messages = this.messages.filter(
(candidate) => candidate.id !== messageId,
);
}
}
async deleteThread(channelId: string): Promise<void> {
this.deletedThreads.push(channelId);
}
async addThreadMembers(channelId: string, userIds: string[]): Promise<void> {
this.addedThreadMembers.push({ channelId, userIds });
}
async pinMessage(channelId: string, messageId: string): Promise<void> {
this.pinnedMessages.push({ channelId, messageId });
}
async sendTyping(): Promise<void> {
this.typingCount += 1;
}
emit(inbound: DiscordInbound): void {
this.handlers?.onInbound(inbound);
}
}
class FakeConsoleOutput implements DiscordConsoleOutput {
messages: DiscordConsoleMessage[] = [];
message(message: DiscordConsoleMessage): void {
this.messages.push(message);
}
}
async function waitFor(
predicate: () => boolean | Promise<boolean>,
timeoutMs = 1000,
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("Timed out waiting for predicate");
}
async function sleep(delayMs: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
function inputText(value: unknown): string {
if (typeof value !== "object" || value === null || !("text" in value)) {
return "";
}
const text = (value as { text?: unknown }).text;
return typeof text === "string" ? text : "";
}
function statusMessageText(transport: FakeDiscordTransport): string {
return transport.messages.find((message) => message.id === "message-out-1")
?.text ?? "";
}