diff --git a/apps/discord-bridge/README.md b/apps/discord-bridge/README.md new file mode 100644 index 0000000..d0c1bba --- /dev/null +++ b/apps/discord-bridge/README.md @@ -0,0 +1,49 @@ +# Codex Discord Bridge + +Long-lived Discord sidecar for connecting Discord to Codex app-server threads. + +## Gateway Mode + +Gateway mode is opt-in. It keeps one Discord home channel as the primary UX and +one main Codex thread as the operator memory for the gateway. Legacy +thread-per-task behavior remains available outside the configured home channel. + +Set these environment values before starting the bridge: + +```bash +CODEX_DISCORD_HOME_CHANNEL_ID=1502107617512919220 +CODEX_DISCORD_MAIN_THREAD_ID=019e2509-ddbb-7380-b97b-41575092d86b +CODEX_DISCORD_ALLOWED_CHANNEL_IDS=1502107617512919220 +CODEX_DISCORD_DIR=/home/peezy/codex-fork-workspace/codex-flows +``` + +`CODEX_DISCORD_MAIN_THREAD_ID` is optional. If omitted, the bridge creates a new +main operator thread and stores it in the bridge state file. + +In the home channel: + +- normal messages are sent to the main operator thread +- bot mentions are treated as gateway messages and do not create Discord task + threads +- `status` replies directly with gateway state instead of starting a Codex turn + +The prompt sent to the main thread uses `[discord-gateway]` framing so the model +knows it is operating as the gateway over the codex-flows backend, not as a +single task thread. + +## Delegation Direction + +Discord should not become a workspace registry. The main operator thread is the +place where routing decisions happen. Future privileged backend or MCP tools +should be attached only to that main thread and expose operations such as: + +- list active Codex sessions or backend runs +- start a delegated Codex session in a requested cwd +- resume a delegated Codex session by thread id +- send a turn to a delegated session +- observe or summarize delegated session state +- dispatch, inspect, or replay flow backend events + +Gateway state already has delegation records for those future tools, including +optional Discord detail thread ids for noisy work. Final results should return +to the home channel even when detail threads are used. diff --git a/apps/discord-bridge/src/bridge.ts b/apps/discord-bridge/src/bridge.ts index 2a5d543..72ca4bf 100644 --- a/apps/discord-bridge/src/bridge.ts +++ b/apps/discord-bridge/src/bridge.ts @@ -101,6 +101,7 @@ export class DiscordCodexBridge { this.client.on("request", (message) => this.#handleServerRequest(message)); await this.client.connect(); this.#debug("client.connected"); + await this.#ensureGatewaySession(); await this.transport.start({ onInbound: (inbound) => { void this.#handleInbound(inbound).catch((error) => { @@ -185,6 +186,10 @@ export class DiscordCodexBridge { } if (inbound.kind === "threadStart") { + if (this.config.gateway?.homeChannelId === inbound.channelId) { + await this.#handleGatewayThreadStart(inbound); + return; + } if (!this.config.allowedUserIds.has(inbound.author.id)) { this.#debug("threadStart.ignored.user", { channelId: inbound.channelId, @@ -473,6 +478,10 @@ export class DiscordCodexBridge { }); return; } + if (this.config.gateway?.homeChannelId === message.channelId) { + await this.#handleGatewayMessage(message); + return; + } const runner = this.#runnersByDiscordThread.get(message.channelId); if (!runner) { this.#debug("message.ignored.noSession", { @@ -501,6 +510,72 @@ export class DiscordCodexBridge { await runner.enqueueMessage(message); } + async #handleGatewayThreadStart(start: DiscordThreadStartInbound): Promise { + await this.#handleGatewayMessage({ + kind: "message", + channelId: start.channelId, + guildId: start.guildId, + messageId: start.sourceMessageId, + author: start.author, + content: threadPrompt(start), + createdAt: start.createdAt, + }); + } + + async #handleGatewayMessage(message: DiscordMessageInbound): Promise { + if (!this.config.allowedUserIds.has(message.author.id)) { + this.#debug("gateway.message.ignored.user", { + channelId: message.channelId, + messageId: message.messageId, + authorId: message.author.id, + }); + return; + } + const command = parseGatewayCommand(message.content); + if (command === "status") { + await this.transport.sendMessage( + message.channelId, + this.#gatewayStatusMessage(), + ); + addProcessedMessageId(this.#requireState(), message.messageId); + await this.#persist(); + return; + } + const runner = this.#gatewayRunner(); + if (!runner) { + this.#debug("gateway.message.ignored.noSession", { + channelId: message.channelId, + messageId: message.messageId, + }); + return; + } + await runner.enqueueMessage(message); + } + + #gatewayStatusMessage(): string { + const state = this.#requireState(); + const gateway = state.gateway; + const session = this.#gatewaySession(); + const delegations = gateway?.delegations ?? []; + const activeDelegations = delegations.filter((delegation) => + delegation.status === "active" + ); + return [ + "**Codex Gateway**", + `Home channel: \`${this.config.gateway?.homeChannelId ?? "disabled"}\``, + `Main thread: \`${session?.codexThreadId ?? gateway?.mainThreadId ?? "none"}\``, + `Dir: \`${session?.cwd ?? this.config.cwd ?? "default"}\``, + `Legacy thread bridge: \`enabled\``, + `Delegations: ${delegations.length} tracked, ${activeDelegations.length} active`, + "", + "**Delegation Backend**", + "Status: prepared for privileged backend/MCP tools; no delegation tool is attached yet.", + "", + "**Detail Threads**", + "Status: optional detail-thread records are supported in state; automatic detail thread mirroring is not enabled yet.", + ].join("\n"); + } + async #handleNotification(message: JsonRpcNotification): Promise { const params = record(message.params); const threadId = stringValue(params.threadId); @@ -549,6 +624,86 @@ export class DiscordCodexBridge { return runner; } + async #ensureGatewaySession(): Promise { + const gatewayConfig = this.config.gateway; + if (!gatewayConfig) { + return; + } + const state = this.#requireState(); + const existing = this.#gatewaySession(); + if (existing) { + state.gateway = { + homeChannelId: gatewayConfig.homeChannelId, + mainThreadId: existing.codexThreadId, + statusMessageId: existing.statusMessageId, + createdAt: existing.createdAt, + delegations: state.gateway?.delegations ?? [], + }; + this.#registerRunner(existing); + await this.#persist(); + return; + } + + const configuredThreadId = + state.gateway?.mainThreadId ?? + gatewayConfig.mainThreadId; + const title = "Codex Gateway"; + const started = configuredThreadId + ? await this.client.resumeThread(this.#threadResumeParams( + configuredThreadId, + this.config.cwd, + )) + : await this.client.startThread(this.#threadStartParams(this.config.cwd)); + const codexThreadId = started.thread.id; + if (!configuredThreadId) { + await this.client.setThreadName({ + threadId: codexThreadId, + name: "[discord-gateway] Codex Gateway", + }); + } + const session: DiscordBridgeSession = { + discordThreadId: gatewayConfig.homeChannelId, + parentChannelId: gatewayConfig.homeChannelId, + codexThreadId, + title, + createdAt: this.#now().toISOString(), + cwd: resumeResponseCwd(started) ?? this.config.cwd, + mode: "gateway", + }; + state.gateway = { + homeChannelId: gatewayConfig.homeChannelId, + mainThreadId: codexThreadId, + createdAt: session.createdAt, + delegations: state.gateway?.delegations ?? [], + }; + state.sessions.push(session); + this.#registerRunner(session); + await this.#persist(); + this.#debug("gateway.session.ready", { + homeChannelId: gatewayConfig.homeChannelId, + codexThreadId, + resumed: Boolean(configuredThreadId), + }); + } + + #gatewaySession(): DiscordBridgeSession | undefined { + const gatewayConfig = this.config.gateway; + if (!gatewayConfig) { + return undefined; + } + return this.#requireState().sessions.find((session) => + session.mode === "gateway" && + session.discordThreadId === gatewayConfig.homeChannelId + ); + } + + #gatewayRunner(): DiscordThreadRunner | undefined { + const session = this.#gatewaySession(); + return session + ? this.#runnersByDiscordThread.get(session.discordThreadId) + : undefined; + } + #isSessionRunning( session: DiscordBridgeSession, state: DiscordBridgeState, @@ -973,6 +1128,13 @@ function isDuplicate(state: DiscordBridgeState, messageId: string): boolean { ); } +function parseGatewayCommand(content: string): "status" | undefined { + const normalized = content.trim().toLowerCase(); + return normalized === "status" || normalized === "/status" + ? "status" + : undefined; +} + function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) diff --git a/apps/discord-bridge/src/config.ts b/apps/discord-bridge/src/config.ts index 48e1903..a749f23 100644 --- a/apps/discord-bridge/src/config.ts +++ b/apps/discord-bridge/src/config.ts @@ -125,6 +125,7 @@ export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfi env.CODEX_DISCORD_ALLOWED_CHANNEL_IDS, ), statePath, + gateway: gatewayConfig(args, env), cwd: resolveHomeDir( stringFlag(args, "dir") ?? stringFlag(args, "positional-dir") ?? @@ -267,6 +268,32 @@ function optionalProgressMode(value: string | undefined): DiscordProgressMode | return value as DiscordProgressMode; } +function gatewayConfig( + flags: Map, + env: NodeJS.ProcessEnv, +): DiscordBridgeConfig["gateway"] { + const homeChannelId = + stringFlag(flags, "home-channel-id") ?? + stringFlag(flags, "gateway-home-channel-id") ?? + env.CODEX_DISCORD_HOME_CHANNEL_ID ?? + env.CODEX_DISCORD_GATEWAY_HOME_CHANNEL_ID; + const mainThreadId = + stringFlag(flags, "main-thread-id") ?? + stringFlag(flags, "gateway-main-thread-id") ?? + env.CODEX_DISCORD_MAIN_THREAD_ID ?? + env.CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID; + if (!homeChannelId) { + if (mainThreadId) { + throw new Error("Cannot set a gateway main thread without a gateway home channel."); + } + return undefined; + } + return { + homeChannelId, + mainThreadId, + }; +} + function optionalConsoleOutput( value: string | undefined, ): DiscordConsoleOutputMode | undefined { @@ -332,6 +359,8 @@ Options: --local-app-server Start a local app-server over stdio --state-path Persistent bridge state file --allowed-channel-ids Comma-separated parent channel ids + --home-channel-id Enable gateway mode for one Discord home channel + --main-thread-id Resume an existing Codex operator thread for gateway mode [dir] Optional Codex thread directory, resolved from home --dir Codex thread directory, resolved from home --cwd Alias for --dir diff --git a/apps/discord-bridge/src/runner.ts b/apps/discord-bridge/src/runner.ts index 56af36e..08a2e83 100644 --- a/apps/discord-bridge/src/runner.ts +++ b/apps/discord-bridge/src/runner.ts @@ -194,7 +194,7 @@ export class DiscordThreadRunner { input: [ { type: "text", - text: formatDiscordPrompt({ + text: this.#formatPrompt({ id: `${message.messageId}-steer`, status: "pending", discordMessageId: message.messageId, @@ -362,7 +362,7 @@ export class DiscordThreadRunner { input: [ { type: "text", - text: formatDiscordPrompt(item), + text: this.#formatPrompt(item), text_elements: [], }, ], @@ -1683,6 +1683,10 @@ export class DiscordThreadRunner { return this.#context.config.progressMode ?? "summary"; } + #formatPrompt(item: DiscordBridgeQueueItem): string { + return formatDiscordPrompt(item, this.session); + } + #emitConsoleMessage( kind: DiscordConsoleMessageKind, turnId: string | undefined, @@ -2057,7 +2061,24 @@ function truncateOneLine(value: string, maxLength: number): string { return `${oneLine.slice(0, maxLength - 3).trimEnd()}...`; } -function formatDiscordPrompt(item: DiscordBridgeQueueItem): string { +function formatDiscordPrompt( + item: DiscordBridgeQueueItem, + session: DiscordBridgeSession, +): string { + if (session.mode === "gateway") { + return [ + "[discord-gateway]", + "Role: You are the main Codex operator thread for a single Discord home channel.", + "Intent: Treat this as a gateway request. Answer directly when appropriate; otherwise reason about backend/runtime delegation without assuming Discord itself owns a workspace registry.", + "Canonical memory: This main Codex thread is the operator memory. Delegated Codex threads remain canonical history for delegated work.", + `Author: ${item.authorName} (${item.authorId})`, + `Message: ${item.discordMessageId}`, + `Home channel: ${item.discordThreadId}`, + `Gateway cwd: ${session.cwd ?? "default"}`, + "", + item.content, + ].join("\n"); + } return [ "[discord]", `Author: ${item.authorName} (${item.authorId})`, diff --git a/apps/discord-bridge/src/state.ts b/apps/discord-bridge/src/state.ts index 25e4be7..1f0014f 100644 --- a/apps/discord-bridge/src/state.ts +++ b/apps/discord-bridge/src/state.ts @@ -9,6 +9,8 @@ import type { DiscordBridgeSession, DiscordBridgeState, DiscordBridgeStateStore, + DiscordGatewayDelegation, + DiscordGatewayState, } from "./types.ts"; const maxProcessedMessageIds = 1000; @@ -58,6 +60,7 @@ export class MemoryStateStore implements DiscordBridgeStateStore { export function emptyState(): DiscordBridgeState { return { version: 1, + gateway: undefined, sessions: [], queue: [], activeTurns: [], @@ -79,6 +82,7 @@ function parseState(value: unknown): DiscordBridgeState { } return { version: 1, + gateway: parseGateway(value.gateway), sessions: Array.isArray(value.sessions) ? value.sessions.map(parseSession) : [], @@ -97,6 +101,53 @@ function parseState(value: unknown): DiscordBridgeState { }; } +function parseGateway(value: unknown): DiscordGatewayState | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + throw new Error("Invalid Discord bridge gateway state"); + } + return { + homeChannelId: requiredString(value.homeChannelId, "gateway.homeChannelId"), + mainThreadId: optionalString(value.mainThreadId), + statusMessageId: optionalString(value.statusMessageId), + createdAt: optionalString(value.createdAt), + delegations: Array.isArray(value.delegations) + ? value.delegations.map(parseGatewayDelegation) + : [], + }; +} + +function parseGatewayDelegation(value: unknown): DiscordGatewayDelegation { + if (!isRecord(value)) { + throw new Error("Invalid Discord bridge gateway delegation"); + } + const status = value.status; + if ( + status !== "active" && + status !== "idle" && + status !== "failed" && + status !== "complete" + ) { + throw new Error("Invalid Discord bridge gateway delegation status"); + } + return { + id: requiredString(value.id, "gateway.delegations.id"), + codexThreadId: requiredString( + value.codexThreadId, + "gateway.delegations.codexThreadId", + ), + title: requiredString(value.title, "gateway.delegations.title"), + status, + cwd: optionalString(value.cwd), + discordDetailThreadId: optionalString(value.discordDetailThreadId), + parentDiscordMessageId: optionalString(value.parentDiscordMessageId), + createdAt: requiredString(value.createdAt, "gateway.delegations.createdAt"), + updatedAt: requiredString(value.updatedAt, "gateway.delegations.updatedAt"), + }; +} + function parseActiveTurn(value: unknown): DiscordBridgeActiveTurn { if (!isRecord(value)) { throw new Error("Invalid Discord bridge active turn"); @@ -208,7 +259,9 @@ function optionalNumber(value: unknown): number | undefined { } function parseSessionMode(value: unknown): DiscordBridgeSession["mode"] { - return value === "new" || value === "resumed" ? value : undefined; + return value === "new" || value === "resumed" || value === "gateway" + ? value + : undefined; } function uniqueStrings(values: unknown[]): string[] { diff --git a/apps/discord-bridge/src/types.ts b/apps/discord-bridge/src/types.ts index ac4f019..e78f74d 100644 --- a/apps/discord-bridge/src/types.ts +++ b/apps/discord-bridge/src/types.ts @@ -10,6 +10,7 @@ export type DiscordBridgeConfig = { allowedUserIds: Set; allowedChannelIds: Set; statePath: string; + gateway?: DiscordGatewayConfig; cwd?: string; model?: string; modelProvider?: string; @@ -30,6 +31,11 @@ export type DiscordBridgeConfig = { export type DiscordProgressMode = "summary" | "commentary" | "none"; export type DiscordConsoleOutputMode = "messages" | "none"; +export type DiscordGatewayConfig = { + homeChannelId: string; + mainThreadId?: string; +}; + export type DiscordAuthor = { id: string; name: string; @@ -127,6 +133,7 @@ export type CodexBridgeClient = { export type DiscordBridgeState = { version: 1; + gateway?: DiscordGatewayState; sessions: DiscordBridgeSession[]; queue: DiscordBridgeQueueItem[]; activeTurns: DiscordBridgeActiveTurn[]; @@ -134,6 +141,26 @@ export type DiscordBridgeState = { deliveries: DiscordBridgeDelivery[]; }; +export type DiscordGatewayState = { + homeChannelId: string; + mainThreadId?: string; + statusMessageId?: string; + createdAt?: string; + delegations: DiscordGatewayDelegation[]; +}; + +export type DiscordGatewayDelegation = { + id: string; + codexThreadId: string; + title: string; + status: "active" | "idle" | "failed" | "complete"; + cwd?: string; + discordDetailThreadId?: string; + parentDiscordMessageId?: string; + createdAt: string; + updatedAt: string; +}; + export type DiscordBridgeSession = { discordThreadId: string; parentChannelId: string; @@ -145,7 +172,7 @@ export type DiscordBridgeSession = { ownerUserId?: string; participantUserIds?: string[]; cwd?: string; - mode?: "new" | "resumed"; + mode?: "new" | "resumed" | "gateway"; statusMessageId?: string; }; diff --git a/apps/discord-bridge/test/bridge.test.ts b/apps/discord-bridge/test/bridge.test.ts index 6865d9b..35efd13 100644 --- a/apps/discord-bridge/test/bridge.test.ts +++ b/apps/discord-bridge/test/bridge.test.ts @@ -36,6 +36,170 @@ describe("DiscordCodexBridge", () => { }); }); + test("starts a gateway main thread and routes home channel messages to it", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore(), + config: testConfig({ + gateway: { homeChannelId: "home-channel" }, + allowedChannelIds: new Set(["parent-channel"]), + }), + }); + + await bridge.start(); + await waitFor(() => bridge.stateForTest().sessions.length === 1); + expect(client.startThreadCalls).toHaveLength(1); + expect(client.setThreadNameCalls[0]).toEqual({ + threadId: "codex-thread-1", + name: "[discord-gateway] Codex Gateway", + }); + expect(bridge.stateForTest().gateway).toEqual( + expect.objectContaining({ + homeChannelId: "home-channel", + mainThreadId: "codex-thread-1", + }), + ); + expect(bridge.stateForTest().sessions[0]).toEqual( + expect.objectContaining({ + discordThreadId: "home-channel", + parentChannelId: "home-channel", + codexThreadId: "codex-thread-1", + title: "Codex Gateway", + cwd: "/workspace", + mode: "gateway", + }), + ); + + transport.emit({ + kind: "message", + channelId: "home-channel", + messageId: "home-message-1", + author: { id: "user-1", name: "Peezy", isBot: false }, + content: "status across the workspaces", + createdAt: "2026-05-14T00:00:00.000Z", + }); + + await waitFor(() => client.startTurnCalls.length === 1); + expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( + "status across the workspaces", + ); + expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( + "[discord-gateway]", + ); + expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( + "main Codex operator thread", + ); + expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( + "Home channel: home-channel", + ); + await bridge.stop(); + }); + + test("answers gateway status in the home channel without starting a turn", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore(), + config: testConfig({ + gateway: { homeChannelId: "home-channel" }, + }), + }); + + await bridge.start(); + await waitFor(() => bridge.stateForTest().sessions.length === 1); + transport.emit({ + kind: "message", + channelId: "home-channel", + messageId: "status-message-1", + author: { id: "user-1", name: "Peezy", isBot: false }, + content: "status", + createdAt: "2026-05-14T00:00:00.000Z", + }); + + await waitFor(() => + transport.messages.some((message) => + message.channelId === "home-channel" && + message.text.includes("**Codex Gateway**") + ) + ); + expect(client.startTurnCalls).toHaveLength(0); + expect(bridge.stateForTest().processedMessageIds).toContain( + "status-message-1", + ); + await bridge.stop(); + }); + + test("resumes a configured gateway main thread without creating Discord threads", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore(), + config: testConfig({ + gateway: { + homeChannelId: "home-channel", + mainThreadId: "codex-main-thread", + }, + }), + }); + + await bridge.start(); + await waitFor(() => bridge.stateForTest().sessions.length === 1); + + expect(client.startThreadCalls).toHaveLength(0); + expect(client.resumeThreadCalls[0]).toEqual( + expect.objectContaining({ threadId: "codex-main-thread" }), + ); + expect(transport.createdThreads).toEqual([]); + expect(bridge.stateForTest().sessions[0]).toEqual( + expect.objectContaining({ + discordThreadId: "home-channel", + codexThreadId: "codex-main-thread", + cwd: "/workspace", + mode: "gateway", + }), + ); + await bridge.stop(); + }); + + test("routes bot mentions in the home channel to the gateway instead of creating threads", async () => { + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore(), + config: testConfig({ + gateway: { homeChannelId: "home-channel" }, + }), + }); + + await bridge.start(); + await waitFor(() => bridge.stateForTest().sessions.length === 1); + transport.emit({ + kind: "threadStart", + channelId: "home-channel", + sourceMessageId: "mention-message-1", + author: { id: "user-1", name: "Peezy", isBot: false }, + prompt: "<@bot-id> in load-game check active work", + mentionedUserIds: ["bot-id"], + createdAt: "2026-05-14T00:00:00.000Z", + }); + + await waitFor(() => client.startTurnCalls.length === 1); + expect(transport.createdThreads).toEqual([]); + expect(inputText(client.startTurnCalls[0]?.input[0])).toContain( + "in load-game check active work", + ); + await bridge.stop(); + }); + test("starts a Discord thread from a mention and sends summaries only after chunks complete", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); diff --git a/apps/discord-bridge/test/config.test.ts b/apps/discord-bridge/test/config.test.ts index eebc606..2333f52 100644 --- a/apps/discord-bridge/test/config.test.ts +++ b/apps/discord-bridge/test/config.test.ts @@ -185,6 +185,58 @@ describe("parseConfig", () => { } }); + test("parses gateway home and main thread ids", () => { + const fromFlag = parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--home-channel-id", + "home-channel", + "--main-thread-id", + "main-thread", + ], + {}, + ); + const fromEnv = parseConfig( + ["--token", "discord-token", "--allowed-user-ids", "user-1"], + { + CODEX_DISCORD_GATEWAY_HOME_CHANNEL_ID: "env-home", + CODEX_DISCORD_GATEWAY_MAIN_THREAD_ID: "env-thread", + }, + ); + + expect(fromFlag.type).toBe("run"); + expect(fromEnv.type).toBe("run"); + if (fromFlag.type === "run" && fromEnv.type === "run") { + expect(fromFlag.config.gateway).toEqual({ + homeChannelId: "home-channel", + mainThreadId: "main-thread", + }); + expect(fromEnv.config.gateway).toEqual({ + homeChannelId: "env-home", + mainThreadId: "env-thread", + }); + } + }); + + test("rejects gateway main thread without home channel", () => { + expect(() => + parseConfig( + [ + "--token", + "discord-token", + "--allowed-user-ids", + "user-1", + "--main-thread-id", + "main-thread", + ], + {}, + ) + ).toThrow("Cannot set a gateway main thread without a gateway home channel."); + }); + test("can force a local app-server even when workspace URL env is set", () => { const parsed = parseConfig( [ diff --git a/apps/discord-bridge/test/state.test.ts b/apps/discord-bridge/test/state.test.ts index f490d68..35671b2 100644 --- a/apps/discord-bridge/test/state.test.ts +++ b/apps/discord-bridge/test/state.test.ts @@ -14,6 +14,25 @@ describe("JsonFileStateStore", () => { statePath, `${JSON.stringify({ version: 1, + gateway: { + homeChannelId: "home-channel", + mainThreadId: "codex-gateway-thread", + statusMessageId: "message-gateway-status", + createdAt: "2026-05-11T00:00:00.000Z", + delegations: [ + { + id: "delegation-1", + codexThreadId: "codex-delegated-thread", + title: "Patchbay webhook work", + status: "active", + cwd: "/workspace/patchbay", + discordDetailThreadId: "discord-detail-thread", + parentDiscordMessageId: "message-parent", + createdAt: "2026-05-11T00:00:01.000Z", + updatedAt: "2026-05-11T00:00:02.000Z", + }, + ], + }, sessions: [ { discordThreadId: "discord-thread-1", @@ -25,7 +44,7 @@ describe("JsonFileStateStore", () => { ownerUserId: "user-1", participantUserIds: ["user-2", "", "user-2", "user-3"], cwd: "/workspace/project", - mode: "resumed", + mode: "gateway", statusMessageId: "message-status-1", }, { @@ -62,6 +81,25 @@ describe("JsonFileStateStore", () => { const state = await new JsonFileStateStore(statePath).load(); + expect(state.gateway).toEqual({ + homeChannelId: "home-channel", + mainThreadId: "codex-gateway-thread", + statusMessageId: "message-gateway-status", + createdAt: "2026-05-11T00:00:00.000Z", + delegations: [ + { + id: "delegation-1", + codexThreadId: "codex-delegated-thread", + title: "Patchbay webhook work", + status: "active", + cwd: "/workspace/patchbay", + discordDetailThreadId: "discord-detail-thread", + parentDiscordMessageId: "message-parent", + createdAt: "2026-05-11T00:00:01.000Z", + updatedAt: "2026-05-11T00:00:02.000Z", + }, + ], + }); expect(state.sessions).toHaveLength(2); expect(state.sessions[0]?.ownerUserId).toBe("user-1"); expect(state.sessions[0]?.sourceMessageId).toBe("message-start-1"); @@ -70,7 +108,7 @@ describe("JsonFileStateStore", () => { "user-3", ]); expect(state.sessions[0]?.cwd).toBe("/workspace/project"); - expect(state.sessions[0]?.mode).toBe("resumed"); + expect(state.sessions[0]?.mode).toBe("gateway"); expect(state.sessions[0]?.statusMessageId).toBe("message-status-1"); expect(state.sessions[1]?.ownerUserId).toBeUndefined(); expect(state.sessions[1]?.sourceMessageId).toBeUndefined();