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 [--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 { 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(); threadCwds = new Map(); threadGoals = new Map(); blockStartTurn = false; #startTurnResolvers: Array<() => void> = []; #notificationListeners: Array<(message: JsonRpcNotification) => void> = []; #requestListeners: Array<(message: JsonRpcRequest) => void> = []; async connect(): Promise {} 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 { this.startThreadCalls.push(params); return { thread: { id: `codex-thread-${this.startThreadCalls.length}` }, } as v2.ThreadStartResponse; } async resumeThread(params: v2.ThreadResumeParams): Promise { 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 { this.setThreadNameCalls.push(params); return {}; } async startTurn(params: v2.TurnStartParams): Promise { this.startTurnCalls.push(params); const turnNumber = this.startTurnCalls.length; if (this.blockStartTurn) { await new Promise((resolve) => { this.#startTurnResolvers.push(resolve); }); } return { turn: { id: `turn-${turnNumber}` }, } as v2.TurnStartResponse; } async steerTurn(params: v2.TurnSteerParams): Promise { this.steerTurnCalls.push(params); return { turnId: params.expectedTurnId }; } async readThread(params: v2.ThreadReadParams): Promise { this.readThreadCalls.push(params); return { thread: { turns: this.threadTurns.get(params.threadId) ?? [] }, } as unknown as v2.ThreadReadResponse; } async getThreadGoal( params: v2.ThreadGoalGetParams, ): Promise { 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 { this.handlers = handlers; } async stop(): Promise {} async registerCommands(): Promise {} async createThread( channelId: string, name: string, sourceMessageId?: string, ): Promise { this.createdThreads.push({ channelId, name, sourceMessageId }); return `discord-thread-${this.createdThreads.length}`; } async sendMessage(channelId: string, text: string): Promise { 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 { 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 { 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 { this.deletedThreads.push(channelId); } async addThreadMembers(channelId: string, userIds: string[]): Promise { this.addedThreadMembers.push({ channelId, userIds }); } async pinMessage(channelId: string, messageId: string): Promise { this.pinnedMessages.push({ channelId, messageId }); } async sendTyping(): Promise { 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, timeoutMs = 1000, ): Promise { 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 { 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 ?? ""; }