Add Discord gateway mode
This commit is contained in:
parent
3e0d9e057b
commit
625c9c85b8
9 changed files with 602 additions and 7 deletions
49
apps/discord-bridge/README.md
Normal file
49
apps/discord-bridge/README.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const params = record(message.params);
|
||||
const threadId = stringValue(params.threadId);
|
||||
|
|
@ -549,6 +624,86 @@ export class DiscordCodexBridge {
|
|||
return runner;
|
||||
}
|
||||
|
||||
async #ensureGatewaySession(): Promise<void> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
|
|
|||
|
|
@ -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<string, string | boolean>,
|
||||
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 <path> Persistent bridge state file
|
||||
--allowed-channel-ids <ids> Comma-separated parent channel ids
|
||||
--home-channel-id <id> Enable gateway mode for one Discord home channel
|
||||
--main-thread-id <id> Resume an existing Codex operator thread for gateway mode
|
||||
[dir] Optional Codex thread directory, resolved from home
|
||||
--dir <path> Codex thread directory, resolved from home
|
||||
--cwd <path> Alias for --dir
|
||||
|
|
|
|||
|
|
@ -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})`,
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type DiscordBridgeConfig = {
|
|||
allowedUserIds: Set<string>;
|
||||
allowedChannelIds: Set<string>;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue