Add Discord gateway mode

This commit is contained in:
matamune 2026-05-14 14:32:23 +00:00
parent 3e0d9e057b
commit 625c9c85b8
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
9 changed files with 602 additions and 7 deletions

View 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.

View file

@ -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>)

View file

@ -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

View file

@ -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})`,

View file

@ -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[] {

View file

@ -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;
};

View file

@ -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();

View file

@ -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(
[

View file

@ -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();