From 5e4235daeab1404e5094418b7b3168df149f2a87 Mon Sep 17 00:00:00 2001 From: matamune Date: Fri, 15 May 2026 16:05:45 +0000 Subject: [PATCH] goals --- apps/discord-bridge/README.md | 10 + apps/discord-bridge/src/bridge.ts | 474 ++++++++++++++++++- apps/discord-bridge/src/discord-transport.ts | 108 ++++- apps/discord-bridge/src/types.ts | 18 + apps/discord-bridge/test/bridge.test.ts | 267 +++++++++++ 5 files changed, 872 insertions(+), 5 deletions(-) diff --git a/apps/discord-bridge/README.md b/apps/discord-bridge/README.md index f420d2b..0f48639 100644 --- a/apps/discord-bridge/README.md +++ b/apps/discord-bridge/README.md @@ -35,6 +35,10 @@ In the home channel: - `/status` replies directly with gateway state instead of starting a Codex turn - `/status` also lists active Codex threads, linking any opened Discord thread and offering private buttons to open active threads that are not yet in Discord +- `/goals` is available from workspace forum posts and opens an ephemeral goal + management picker for that workspace +- `/goals` inside an opened Codex Discord thread manages that specific thread's + goal; use the slash options to set the objective/status/token budget or clear it 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 @@ -108,6 +112,12 @@ When the workbench is enabled: Discord task thread in the task thread channel - `/status` shows all active Codex threads across workspaces and uses the same ephemeral button flow to open active threads without Discord task threads +- `/goals` in workspace forum posts lists recent workspace thread goals and lets + the command sender mark existing goals active, paused, or complete, clear + them, or open the thread into Discord +- `/goals` in an opened Discord task thread scopes CRUD to that Codex thread: + no options reads the current goal, `objective`/`status`/`token_budget` create + or update it, and `clear` removes it - repeated delegations in the same cwd reuse the same workspace post and update the workspace thread list - Stop lifecycle events update the workspace dashboard and any already-opened diff --git a/apps/discord-bridge/src/bridge.ts b/apps/discord-bridge/src/bridge.ts index 1e633f6..7d9fc80 100644 --- a/apps/discord-bridge/src/bridge.ts +++ b/apps/discord-bridge/src/bridge.ts @@ -35,6 +35,7 @@ import type { DiscordBridgeTransport, DiscordClearInbound, DiscordClearWebhooksInbound, + DiscordGoalsInbound, DiscordInbound, DiscordMessageInbound, DiscordReactionInbound, @@ -84,6 +85,25 @@ type WorkspaceThreadPicker = { entries: WorkspaceThreadSummary[]; }; +type WorkspaceGoalSummary = WorkspaceThreadSummary & { + goal?: v2.ThreadGoal | null; + goalError?: string; +}; + +type WorkspaceGoalPicker = { + channelId: string; + authorId: string; + workspace: DiscordGatewayWorkspaceSurface; + entries: WorkspaceGoalSummary[]; +}; + +type WorkspaceGoalActionPicker = { + channelId: string; + authorId: string; + workspace: DiscordGatewayWorkspaceSurface; + entry: WorkspaceGoalSummary; +}; + export class DiscordCodexBridge { readonly client: CodexBridgeClient; readonly transport: DiscordBridgeTransport; @@ -103,6 +123,8 @@ export class DiscordCodexBridge { #transportStarted = false; #threadPickersByMessage = new Map(); #threadPickersById = new Map(); + #goalPickersById = new Map(); + #goalActionPickersById = new Map(); constructor(options: { client: CodexBridgeClient; @@ -265,6 +287,10 @@ export class DiscordCodexBridge { await this.#handleThreadsCommand(inbound); return; } + if (inbound.kind === "goals") { + await this.#handleGoalsCommand(inbound); + return; + } if (inbound.kind === "threadPicker") { await this.#handleThreadPickerSelection(inbound); return; @@ -526,6 +552,119 @@ export class DiscordCodexBridge { } } + async #handleGoalsCommand(command: DiscordGoalsInbound): Promise { + if (!this.config.allowedUserIds.has(command.author.id)) { + this.#debug("goals.ignored.user", { + channelId: command.channelId, + authorId: command.author.id, + }); + await command.reply?.("Only globally allowed Discord users can manage goals."); + return; + } + if (!this.#isAllowedChannel(command.channelId)) { + this.#debug("goals.ignored.channel", { channelId: command.channelId }); + await command.reply?.("This Discord channel is not allowed for the bridge."); + return; + } + const session = this.#sessionForDiscordThread(command.channelId); + if (session) { + await this.#handleThreadGoalsCommand(command, session); + return; + } + const workspace = this.#workspaceForumForChannel(command.channelId); + if (!workspace) { + await command.reply?.( + "Run `/goals` in a workspace forum post or opened Codex thread.", + ); + return; + } + if (!command.replyPicker) { + await command.reply?.( + "This Discord transport cannot send ephemeral goal pickers.", + ); + return; + } + const entries = await this.#listWorkspaceGoalSummaries(workspace); + if (entries.length === 0) { + await command.reply?.(`No Codex threads found for ${workspace.title}.`); + return; + } + const pickerEntries = entries.slice(0, threadPickerReactions.length); + const pickerId = `goals-${randomUUID()}`; + this.#goalPickersById.set(pickerId, { + channelId: command.channelId, + authorId: command.author.id, + workspace, + entries: pickerEntries, + }); + try { + await command.replyPicker({ + pickerId, + text: goalPickerText(workspace, pickerEntries, entries.length), + options: pickerEntries.map((_, index) => ({ + id: String(index), + label: String(index + 1), + })), + }); + } catch (error) { + this.#goalPickersById.delete(pickerId); + await command.reply?.( + `Failed to send the goal picker: ${errorMessage(error)}`, + ); + } + } + + async #handleThreadGoalsCommand( + command: DiscordGoalsInbound, + session: DiscordBridgeSession, + ): Promise { + const hasMutation = hasGoalMutation(command); + if (command.clear && hasMutation) { + await command.reply?.("Use either `clear` or goal updates, not both."); + return; + } + const workspace = this.#workspaceForGoalSession(session); + const picker = { + channelId: command.channelId, + authorId: command.author.id, + workspace, + }; + if (command.clear) { + try { + await this.client.clearThreadGoal({ threadId: session.codexThreadId }); + await command.reply?.(`Cleared goal for ${session.title}.`); + } catch (error) { + await command.reply?.( + `Failed to clear goal for ${session.title}: ${errorMessage(error)}`, + ); + } + return; + } + if (hasMutation) { + try { + const response = await this.client.setThreadGoal({ + threadId: session.codexThreadId, + objective: command.objective, + status: command.goalStatus, + tokenBudget: command.tokenBudget, + }); + await this.#showGoalActionPicker( + command, + picker, + this.#goalSummaryFromSession(session, { goal: response.goal }), + { prefix: command.objective ? "Saved goal." : "Updated goal." }, + ); + } catch (error) { + await command.reply?.( + `Failed to update goal for ${session.title}: ${errorMessage(error)}`, + ); + } + return; + } + const entry = await this.#goalSummaryForSession(session); + await this.#showGoalActionPicker(command, picker, entry); + } + async #handleThreadPickerSelection( selection: DiscordThreadPickerInbound, ): Promise { @@ -533,12 +672,29 @@ export class DiscordCodexBridge { return; } const picker = this.#threadPickersById.get(selection.pickerId); - if (!picker) { - await selection.update?.("This thread picker is no longer active."); + if (picker) { + await this.#handleWorkspaceThreadPickerSelection(selection, picker); return; } + const goalPicker = this.#goalPickersById.get(selection.pickerId); + if (goalPicker) { + await this.#handleGoalPickerSelection(selection, goalPicker); + return; + } + const goalActionPicker = this.#goalActionPickersById.get(selection.pickerId); + if (goalActionPicker) { + await this.#handleGoalActionSelection(selection, goalActionPicker); + return; + } + await selection.update?.("This picker is no longer active."); + } + + async #handleWorkspaceThreadPickerSelection( + selection: DiscordThreadPickerInbound, + picker: WorkspaceThreadPicker, + ): Promise { if (selection.author.id !== picker.authorId) { - await selection.reply?.("Only the user who ran `/threads` can use this picker."); + await selection.reply?.("Only the user who ran the command can use this picker."); return; } const index = Number.parseInt(selection.optionId, 10); @@ -570,6 +726,130 @@ export class DiscordCodexBridge { } } + async #handleGoalPickerSelection( + selection: DiscordThreadPickerInbound, + picker: WorkspaceGoalPicker, + ): Promise { + if (selection.author.id !== picker.authorId) { + await selection.reply?.("Only the user who ran `/goals` can use this picker."); + return; + } + const index = Number.parseInt(selection.optionId, 10); + const entry = Number.isInteger(index) ? picker.entries[index] : undefined; + if (!entry) { + await selection.update?.("That goal choice is no longer available."); + return; + } + this.#goalPickersById.delete(selection.pickerId); + await this.#showGoalActionPicker(selection, picker, entry); + } + + async #handleGoalActionSelection( + selection: DiscordThreadPickerInbound, + picker: WorkspaceGoalActionPicker, + ): Promise { + if (selection.author.id !== picker.authorId) { + await selection.reply?.("Only the user who ran `/goals` can use this picker."); + return; + } + const action = selection.optionId; + this.#goalActionPickersById.delete(selection.pickerId); + if (action === "open") { + try { + const session = await this.#materializeWorkspaceThread(picker.entry.id, { + author: selection.author, + }); + const updatedEntry = { + ...picker.entry, + discordThreadId: session.discordThreadId, + }; + await this.#showGoalActionPicker(selection, picker, updatedEntry, { + prefix: `Opened ${session.title}: <#${session.discordThreadId}>`, + }); + } catch (error) { + await updateOrReply( + selection, + `Failed to open ${picker.entry.title}: ${errorMessage(error)}`, + ); + } + return; + } + if (action === "clear") { + try { + await this.client.clearThreadGoal({ threadId: picker.entry.id }); + await updateOrReply( + selection, + `Cleared goal for ${picker.entry.title}.`, + ); + } catch (error) { + await updateOrReply( + selection, + `Failed to clear goal for ${picker.entry.title}: ${errorMessage(error)}`, + ); + } + return; + } + const status = action.startsWith("status:") + ? action.slice("status:".length) + : ""; + if ( + status === "active" || + status === "paused" || + status === "budgetLimited" || + status === "complete" + ) { + try { + const response = await this.client.setThreadGoal({ + threadId: picker.entry.id, + status, + }); + await this.#showGoalActionPicker( + selection, + picker, + { ...picker.entry, goal: response.goal }, + { prefix: `Set goal status to ${status}.` }, + ); + } catch (error) { + await updateOrReply( + selection, + `Failed to update goal for ${picker.entry.title}: ${errorMessage(error)}`, + ); + } + return; + } + await selection.update?.("That goal action is no longer available."); + } + + async #showGoalActionPicker( + selection: Pick< + DiscordThreadPickerInbound, + "update" | "updatePicker" | "reply" + > & Pick, + picker: Pick, + entry: WorkspaceGoalSummary, + options: { prefix?: string } = {}, + ): Promise { + const actions = goalActionOptions(entry); + const text = goalActionText(picker.workspace, entry, options); + const sendPicker = selection.updatePicker ?? selection.replyPicker; + if (actions.length === 0 || !sendPicker) { + await updateOrReply(selection, text); + return; + } + const pickerId = `goal-actions-${randomUUID()}`; + this.#goalActionPickersById.set(pickerId, { + channelId: picker.channelId, + authorId: picker.authorId, + workspace: picker.workspace, + entry, + }); + await sendPicker({ + pickerId, + text, + options: actions, + }); + } + async #handleThreadPickerReaction(reaction: DiscordReactionInbound): Promise { if (!this.config.allowedUserIds.has(reaction.author.id)) { return; @@ -1861,6 +2141,59 @@ export class DiscordCodexBridge { return [...byId.values()].sort((left, right) => right.updatedAt - left.updatedAt); } + async #listWorkspaceGoalSummaries( + workspace: DiscordGatewayWorkspaceSurface, + ): Promise { + const threads = (await this.#listWorkspaceThreads(workspace)).slice( + 0, + threadPickerReactions.length, + ); + return await Promise.all( + threads.map(async (thread) => { + try { + const response = await this.client.getThreadGoal({ + threadId: thread.id, + }); + return { ...thread, goal: response.goal }; + } catch (error) { + return { ...thread, goalError: errorMessage(error) }; + } + }), + ); + } + + async #goalSummaryForSession( + session: DiscordBridgeSession, + ): Promise { + try { + const response = await this.client.getThreadGoal({ + threadId: session.codexThreadId, + }); + return this.#goalSummaryFromSession(session, { goal: response.goal }); + } catch (error) { + return this.#goalSummaryFromSession(session, { + goalError: errorMessage(error), + }); + } + } + + #goalSummaryFromSession( + session: DiscordBridgeSession, + options: Pick = {}, + ): WorkspaceGoalSummary { + return { + id: session.codexThreadId, + title: session.title, + cwd: session.cwd ?? this.config.cwd ?? process.cwd(), + status: this.#isSessionRunning(session, this.#requireState()) + ? "active" + : "open", + updatedAt: Date.parse(session.createdAt) / 1000, + discordThreadId: session.discordThreadId, + ...options, + }; + } + async #listActiveCodexThreadSummaries(): Promise { const byId = new Map(); const put = (summary: WorkspaceThreadSummary) => { @@ -2044,6 +2377,47 @@ export class DiscordCodexBridge { return workspaces.find((workspace) => workspace.key === key); } + #sessionForDiscordThread(channelId: string): DiscordBridgeSession | undefined { + const session = this.#requireState().sessions.find((candidate) => + candidate.discordThreadId === channelId + ); + if ( + !session || + session.mode === "gateway" || + session.discordThreadId === session.parentChannelId + ) { + return undefined; + } + return session; + } + + #workspaceForGoalSession( + session: DiscordBridgeSession, + ): DiscordGatewayWorkspaceSurface { + const existing = this.#workspaceForChannel(session.discordThreadId); + if (existing) { + return existing; + } + const cwd = workspaceCwdForPath(session.cwd, this.config.cwd); + return { + key: workspaceKey(cwd), + cwd, + title: workspaceTitle(cwd), + discordThreadId: session.parentChannelId, + delegationIds: [], + createdAt: session.createdAt, + updatedAt: session.createdAt, + }; + } + + #workspaceForumForChannel( + channelId: string, + ): DiscordGatewayWorkspaceSurface | undefined { + return this.#requireState().gateway?.workspaces?.find((workspace) => + workspace.discordThreadId === channelId + ); + } + async #mirrorDelegationResultToTaskThread( delegation: DiscordGatewayDelegation, ): Promise { @@ -3241,6 +3615,100 @@ function activeThreadStatusLines( }); } +function goalPickerText( + workspace: DiscordGatewayWorkspaceSurface, + entries: WorkspaceGoalSummary[], + total: number, +): string { + return [ + `**Goals: ${workspace.title}**`, + `Dir: \`${workspace.cwd}\``, + "", + ...entries.map((entry, index) => { + const link = entry.discordThreadId ? `<#${entry.discordThreadId}>` : "`not opened`"; + const title = truncateDiscordThreadName(entry.title); + return `${threadPickerReactions[index]} ${link} ${title} - ${goalSummaryText(entry)}`; + }), + total > entries.length ? `Showing newest ${entries.length} of ${total}.` : undefined, + "", + "Choose a number to manage that thread's goal.", + ].filter((line): line is string => line !== undefined).join("\n"); +} + +function goalActionText( + workspace: DiscordGatewayWorkspaceSurface, + entry: WorkspaceGoalSummary, + options: { prefix?: string } = {}, +): string { + const link = entry.discordThreadId ? `<#${entry.discordThreadId}>` : "`not opened`"; + const goal = entry.goal; + return [ + options.prefix, + `**Goal: ${truncateDiscordThreadName(entry.title)}**`, + `Workspace: ${workspace.title}`, + `Thread: ${link} \`${entry.id}\``, + `Dir: \`${entry.cwd}\``, + "", + entry.goalError + ? `Goal: unavailable (${entry.goalError})` + : goal + ? [ + `Goal: \`${goal.status}\` ${previewText(firstLine(goal.objective) ?? goal.objective, 180)}`, + `Usage: ${goal.tokensUsed} tokens, ${Math.round(goal.timeUsedSeconds)}s${ + goal.tokenBudget ? ` of ${goal.tokenBudget} tokens` : "" + }`, + ].join("\n") + : "Goal: none", + "", + goalActionOptions(entry).length > 0 + ? "Choose an action." + : entry.goal + ? "No goal actions are available for this thread." + : "Use `/goals objective:` in an opened Discord thread to create one.", + ].filter((line): line is string => line !== undefined).join("\n"); +} + +function hasGoalMutation(command: DiscordGoalsInbound): boolean { + return command.objective !== undefined || + command.goalStatus !== undefined || + command.tokenBudget !== undefined; +} + +function goalActionOptions( + entry: WorkspaceGoalSummary, +): Array<{ id: string; label: string }> { + const options: Array<{ id: string; label: string }> = []; + if (!entry.discordThreadId) { + options.push({ id: "open", label: "Open" }); + } + if (entry.goal && !entry.goalError) { + if (entry.goal.status !== "active") { + options.push({ id: "status:active", label: "Active" }); + } + if (entry.goal.status !== "paused") { + options.push({ id: "status:paused", label: "Pause" }); + } + if (entry.goal.status !== "complete") { + options.push({ id: "status:complete", label: "Complete" }); + } + options.push({ id: "clear", label: "Clear" }); + } + return options; +} + +function goalSummaryText(entry: WorkspaceGoalSummary): string { + if (entry.goalError) { + return `goal unavailable (${entry.goalError})`; + } + if (!entry.goal) { + return "no goal"; + } + return `\`${entry.goal.status}\` ${previewText( + firstLine(entry.goal.objective) ?? entry.goal.objective, + 120, + )}`; +} + function threadPickerText( workspace: DiscordGatewayWorkspaceSurface, threads: WorkspaceThreadSummary[], diff --git a/apps/discord-bridge/src/discord-transport.ts b/apps/discord-bridge/src/discord-transport.ts index f1c262b..656f2cc 100644 --- a/apps/discord-bridge/src/discord-transport.ts +++ b/apps/discord-bridge/src/discord-transport.ts @@ -16,6 +16,7 @@ import { } from "discord.js"; import { splitDiscordMessage } from "./bridge.ts"; +import type { v2 } from "@peezy.tech/codex-flows/generated"; import { createDiscordBridgeLogger, type DiscordBridgeLogger, @@ -459,6 +460,13 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport { allowedMentions: emptyAllowedMentions(), }); }; + const updatePicker = async (picker: DiscordEphemeralPicker) => { + await interaction.editReply({ + content: picker.text, + components: threadPickerComponents(picker), + allowedMentions: emptyAllowedMentions(), + }); + }; this.#handlers?.onInbound({ kind: "threadPicker", channelId: interaction.channelId, @@ -475,6 +483,7 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport { createdAt: new Date().toISOString(), reply, update, + updatePicker, }); return; } @@ -485,12 +494,17 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport { interaction.commandName !== "clear" && interaction.commandName !== "clear-webhooks" && interaction.commandName !== "status" && - interaction.commandName !== "threads" + interaction.commandName !== "threads" && + interaction.commandName !== "goals" ) { return; } const channelId = interaction.channelId; - if (interaction.commandName === "status" || interaction.commandName === "threads") { + if ( + interaction.commandName === "status" || + interaction.commandName === "threads" || + interaction.commandName === "goals" + ) { await interaction.deferReply({ ephemeral: true }); } const reply = async (text: string) => { @@ -588,6 +602,48 @@ export class DiscordJsBridgeTransport implements DiscordBridgeTransport { }); return; } + if (interaction.commandName === "goals") { + const goalStatus = goalStatusFromString( + interaction.options.getString("status"), + ); + const objective = interaction.options.getString("objective")?.trim() || + undefined; + const tokenBudget = interaction.options.getInteger("token_budget") ?? + undefined; + const clear = interaction.options.getBoolean("clear") ?? undefined; + const replyPicker = async (picker: DiscordEphemeralPicker) => { + const payload = { + content: picker.text, + components: threadPickerComponents(picker), + allowedMentions: emptyAllowedMentions(), + }; + if (interaction.deferred || interaction.replied) { + await interaction.editReply(payload); + return; + } + await interaction.reply({ ...payload, ephemeral: true }); + }; + this.#handlers?.onInbound({ + kind: "goals", + channelId, + guildId: interaction.guildId ?? undefined, + author: { + id: interaction.user.id, + name: interaction.member && "displayName" in interaction.member + ? String(interaction.member.displayName) + : interaction.user.globalName || interaction.user.username, + isBot: interaction.user.bot, + }, + createdAt: new Date().toISOString(), + objective, + goalStatus, + tokenBudget, + clear, + reply, + replyPicker, + }); + return; + } this.#handlers?.onInbound({ kind: "clear", channelId, @@ -682,6 +738,42 @@ function discordBridgeCommands(): ApplicationCommandDataResolvable[] { name: "threads", description: "List Codex threads for this workspace", }, + { + name: "goals", + description: "Manage Codex goals for this workspace or thread", + options: [ + { + name: "objective", + description: "Create or update this thread's goal objective", + type: 3, + required: false, + }, + { + name: "status", + description: "Set this thread's goal status", + type: 3, + required: false, + choices: [ + { name: "active", value: "active" }, + { name: "paused", value: "paused" }, + { name: "budget limited", value: "budgetLimited" }, + { name: "complete", value: "complete" }, + ], + }, + { + name: "token_budget", + description: "Optional token budget for this thread's goal", + type: 4, + required: false, + }, + { + name: "clear", + description: "Clear this thread's goal", + type: 5, + required: false, + }, + ], + }, ]; } @@ -691,6 +783,18 @@ function commandName(command: ApplicationCommandDataResolvable): string { : "unknown"; } +function goalStatusFromString(value: string | null): v2.ThreadGoalStatus | undefined { + if ( + value === "active" || + value === "paused" || + value === "budgetLimited" || + value === "complete" + ) { + return value; + } + return undefined; +} + function threadPickerComponents( picker: DiscordEphemeralPicker, ): ActionRowBuilder[] { diff --git a/apps/discord-bridge/src/types.ts b/apps/discord-bridge/src/types.ts index f61117f..f13b9df 100644 --- a/apps/discord-bridge/src/types.ts +++ b/apps/discord-bridge/src/types.ts @@ -108,6 +108,20 @@ export type DiscordThreadsInbound = { replyPicker?: (picker: DiscordEphemeralPicker) => Promise; }; +export type DiscordGoalsInbound = { + kind: "goals"; + channelId: string; + guildId?: string; + author: DiscordAuthor; + createdAt: string; + objective?: string; + goalStatus?: v2.ThreadGoalStatus; + tokenBudget?: number; + clear?: boolean; + reply?: (text: string) => Promise; + replyPicker?: (picker: DiscordEphemeralPicker) => Promise; +}; + export type DiscordThreadPickerInbound = { kind: "threadPicker"; channelId: string; @@ -118,6 +132,7 @@ export type DiscordThreadPickerInbound = { createdAt: string; reply?: (text: string) => Promise; update?: (text: string) => Promise; + updatePicker?: (picker: DiscordEphemeralPicker) => Promise; }; export type DiscordReactionInbound = { @@ -137,6 +152,7 @@ export type DiscordInbound = | DiscordClearWebhooksInbound | DiscordStatusInbound | DiscordThreadsInbound + | DiscordGoalsInbound | DiscordThreadPickerInbound | DiscordReactionInbound; @@ -204,7 +220,9 @@ export type CodexBridgeClient = { readThread(params: v2.ThreadReadParams): Promise; injectThreadItems(params: v2.ThreadInjectItemsParams): Promise; listThreads(params: v2.ThreadListParams): Promise; + setThreadGoal(params: v2.ThreadGoalSetParams): Promise; getThreadGoal(params: v2.ThreadGoalGetParams): Promise; + clearThreadGoal(params: v2.ThreadGoalClearParams): Promise; respond(id: string | number, result: unknown): void; respondError(id: string | number, code: number, message: string, data?: unknown): void; }; diff --git a/apps/discord-bridge/test/bridge.test.ts b/apps/discord-bridge/test/bridge.test.ts index 8045052..df122a5 100644 --- a/apps/discord-bridge/test/bridge.test.ts +++ b/apps/discord-bridge/test/bridge.test.ts @@ -1387,6 +1387,237 @@ describe("DiscordCodexBridge", () => { } }); + test("goals command manages thread goals from workspace forum posts", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "discord-goals-")); + await mkdir(path.join(root, "alpha", "project"), { recursive: true }); + const client = new FakeCodexClient(); + client.threads = [ + testThread({ + id: "codex-goal", + cwd: path.join(root, "alpha", "project"), + name: "Goal thread", + updatedAt: 30, + }), + ]; + client.threadGoals.set("codex-goal", { + threadId: "codex-goal", + objective: "Ship goal management", + status: "active", + tokenBudget: null, + tokensUsed: 42, + timeUsedSeconds: 9, + createdAt: 1, + updatedAt: 2, + }); + const transport = new FakeDiscordTransport(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore(), + config: testConfig({ + cwd: root, + gateway: { + homeChannelId: "home-channel", + workspaceForumChannelId: "workspace-forum", + taskThreadsChannelId: "task-channel", + }, + }), + }); + + try { + await bridge.start(); + expect(transport.createdForumPosts.map((post) => post.name)).toEqual([ + "alpha", + ]); + + const replies: string[] = []; + transport.emit({ + kind: "goals", + channelId: "task-channel", + author: { id: "user-1", name: "Peezy", isBot: false }, + createdAt: "2026-05-15T00:00:00.000Z", + reply: async (text) => { + replies.push(text); + }, + replyPicker: transport.threadsReplyPicker(), + }); + await waitFor(() => replies.length === 1); + expect(replies[0]).toBe( + "Run `/goals` in a workspace forum post or opened Codex thread.", + ); + + transport.emit({ + kind: "goals", + channelId: "forum-post-1", + author: { id: "user-1", name: "Peezy", isBot: false }, + createdAt: "2026-05-15T00:00:01.000Z", + reply: async (text) => { + replies.push(text); + }, + replyPicker: transport.threadsReplyPicker(), + }); + await waitFor(() => transport.ephemeralPickers.length === 1); + const picker = transport.ephemeralPickers[0]; + expect(picker?.text).toContain("**Goals: alpha**"); + expect(picker?.text).toContain("1️⃣ `not opened` Goal thread - `active` Ship goal management"); + expect(picker?.options).toEqual([{ id: "0", label: "1" }]); + + transport.emitThreadPicker({ + pickerId: picker?.pickerId ?? "", + optionId: "0", + }); + await waitFor(() => transport.ephemeralPickers.length === 2); + const actionPicker = transport.ephemeralPickers[1]; + expect(actionPicker?.text).toContain("**Goal: Goal thread**"); + expect(actionPicker?.text).toContain("Goal: `active` Ship goal management"); + expect(actionPicker?.options).toEqual([ + { id: "open", label: "Open" }, + { id: "status:paused", label: "Pause" }, + { id: "status:complete", label: "Complete" }, + { id: "clear", label: "Clear" }, + ]); + + transport.emitThreadPicker({ + pickerId: actionPicker?.pickerId ?? "", + optionId: "status:complete", + }); + await waitFor(() => client.setThreadGoalCalls.length === 1); + expect(client.setThreadGoalCalls[0]).toEqual({ + threadId: "codex-goal", + status: "complete", + }); + await waitFor(() => transport.ephemeralPickers.length === 3); + expect(transport.ephemeralPickers[2]?.text).toContain( + "Set goal status to complete.", + ); + expect(transport.ephemeralPickers[2]?.text).toContain( + "Goal: `complete` Ship goal management", + ); + } finally { + await bridge.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + + test("goals command manages the current Discord thread goal", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "discord-thread-goals-")); + const cwd = path.join(root, "alpha", "project"); + await mkdir(cwd, { recursive: true }); + const client = new FakeCodexClient(); + const transport = new FakeDiscordTransport(); + const bridge = new DiscordCodexBridge({ + client, + transport, + store: new MemoryStateStore({ + ...emptyState(), + sessions: [ + { + discordThreadId: "task-goal", + parentChannelId: "task-channel", + codexThreadId: "codex-thread-goal", + title: "Goal task", + createdAt: "2026-05-14T11:00:00.000Z", + cwd, + mode: "workspace", + }, + ], + }), + config: testConfig({ + cwd: root, + gateway: { + homeChannelId: "home-channel", + workspaceForumChannelId: "workspace-forum", + taskThreadsChannelId: "task-channel", + }, + }), + }); + + try { + await bridge.start(); + + const replies: string[] = []; + transport.emit({ + kind: "goals", + channelId: "task-goal", + author: { id: "user-1", name: "Peezy", isBot: false }, + createdAt: "2026-05-15T00:00:00.000Z", + objective: "Improve delegation CRUD", + goalStatus: "active", + tokenBudget: 1234, + reply: async (text) => { + replies.push(text); + }, + replyPicker: transport.threadsReplyPicker(), + }); + await waitFor(() => client.setThreadGoalCalls.length === 1); + expect(client.setThreadGoalCalls[0]).toEqual({ + threadId: "codex-thread-goal", + objective: "Improve delegation CRUD", + status: "active", + tokenBudget: 1234, + }); + await waitFor(() => transport.ephemeralPickers.length === 1); + expect(transport.ephemeralPickers[0]?.text).toContain("Saved goal."); + expect(transport.ephemeralPickers[0]?.text).toContain( + "Goal: `active` Improve delegation CRUD", + ); + + transport.emit({ + kind: "goals", + channelId: "task-goal", + author: { id: "user-1", name: "Peezy", isBot: false }, + createdAt: "2026-05-15T00:00:01.000Z", + reply: async (text) => { + replies.push(text); + }, + replyPicker: transport.threadsReplyPicker(), + }); + await waitFor(() => transport.ephemeralPickers.length === 2); + const picker = transport.ephemeralPickers[1]; + expect(picker?.text).toContain("**Goal: Goal task**"); + expect(picker?.text).toContain("Thread: <#task-goal> `codex-thread-goal`"); + expect(picker?.options).toEqual([ + { id: "status:paused", label: "Pause" }, + { id: "status:complete", label: "Complete" }, + { id: "clear", label: "Clear" }, + ]); + + transport.emitThreadPicker({ + pickerId: picker?.pickerId ?? "", + optionId: "status:complete", + }); + await waitFor(() => client.setThreadGoalCalls.length === 2); + expect(client.setThreadGoalCalls[1]).toEqual({ + threadId: "codex-thread-goal", + status: "complete", + }); + await waitFor(() => transport.ephemeralPickers.length === 3); + expect(transport.ephemeralPickers[2]?.text).toContain( + "Set goal status to complete.", + ); + + transport.emit({ + kind: "goals", + channelId: "task-goal", + author: { id: "user-1", name: "Peezy", isBot: false }, + createdAt: "2026-05-15T00:00:02.000Z", + clear: true, + reply: async (text) => { + replies.push(text); + }, + replyPicker: transport.threadsReplyPicker(), + }); + await waitFor(() => client.clearThreadGoalCalls.length === 1); + expect(client.clearThreadGoalCalls[0]).toEqual({ + threadId: "codex-thread-goal", + }); + await waitFor(() => replies.includes("Cleared goal for Goal task.")); + } finally { + await bridge.stop(); + await rm(root, { recursive: true, force: true }); + } + }); + test("resumes a configured gateway main thread without creating Discord threads", async () => { const client = new FakeCodexClient(); const transport = new FakeDiscordTransport(); @@ -3876,6 +4107,8 @@ class FakeCodexClient implements CodexBridgeClient { injectThreadItemsCalls: v2.ThreadInjectItemsParams[] = []; listThreadsCalls: v2.ThreadListParams[] = []; getThreadGoalCalls: v2.ThreadGoalGetParams[] = []; + setThreadGoalCalls: v2.ThreadGoalSetParams[] = []; + clearThreadGoalCalls: v2.ThreadGoalClearParams[] = []; responses: Array<{ id: string | number; result: unknown }> = []; responseErrors: Array<{ id: string | number; @@ -4010,6 +4243,33 @@ class FakeCodexClient implements CodexBridgeClient { }; } + async setThreadGoal( + params: v2.ThreadGoalSetParams, + ): Promise { + this.setThreadGoalCalls.push(params); + const existing = this.threadGoals.get(params.threadId); + const goal: v2.ThreadGoal = { + threadId: params.threadId, + objective: params.objective ?? existing?.objective ?? "Goal", + status: params.status ?? existing?.status ?? "active", + tokenBudget: params.tokenBudget ?? existing?.tokenBudget ?? null, + tokensUsed: existing?.tokensUsed ?? 0, + timeUsedSeconds: existing?.timeUsedSeconds ?? 0, + createdAt: existing?.createdAt ?? 1, + updatedAt: (existing?.updatedAt ?? 1) + 1, + }; + this.threadGoals.set(params.threadId, goal); + return { goal }; + } + + async clearThreadGoal( + params: v2.ThreadGoalClearParams, + ): Promise { + this.clearThreadGoalCalls.push(params); + const cleared = this.threadGoals.delete(params.threadId); + return { cleared }; + } + respond(id: string | number, result: unknown): void { this.responses.push({ id, result }); } @@ -4232,6 +4492,13 @@ class FakeDiscordTransport implements DiscordBridgeTransport { reply: async (text) => { this.ephemeralUpdates.push({ pickerId: input.pickerId, text }); }, + updatePicker: async (picker) => { + this.ephemeralUpdates.push({ + pickerId: input.pickerId, + text: picker.text, + }); + this.ephemeralPickers.push(picker); + }, }); }