From 2891265a584425414ff1d1de0c90ce17d1ff3939 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 21:08:07 +0200 Subject: [PATCH 1/2] feat(discord-bot): keep mid-turn steer default with --steer/--queue After server queue-by-default, Discord follow-ups would park instead of steering. Restore historical steer-by-default for busy threads, and document --steer / --queue (and slash options) so users can force either path. --- .../discord-bot/src/features/MentionRouter.ts | 66 ++++++++++++++++--- .../src/presentation/channelInfoPin.test.ts | 1 + .../src/presentation/channelInfoPin.ts | 3 +- .../src/presentation/mentions.test.ts | 17 +++++ apps/discord-bot/src/presentation/mentions.ts | 34 ++++++++++ .../src/presentation/slashCommands.ts | 25 ++++++- apps/discord-bot/src/t3/T3Session.ts | 17 +++++ 7 files changed, 151 insertions(+), 12 deletions(-) diff --git a/apps/discord-bot/src/features/MentionRouter.ts b/apps/discord-bot/src/features/MentionRouter.ts index e849578628c..d19fcf7eb87 100644 --- a/apps/discord-bot/src/features/MentionRouter.ts +++ b/apps/discord-bot/src/features/MentionRouter.ts @@ -1,10 +1,11 @@ // @effect-diagnostics anyUnknownInErrorContext:off missingEffectContext:off globalDate:off globalErrorInEffectFailure:off outdatedApi:off globalFetchInEffect:off -import type { - ModelSelection, - OrchestrationThread, - ServerProvider, - ThreadId, - UploadChatAttachment, +import { + MessageId, + type ModelSelection, + type OrchestrationThread, + type ServerProvider, + type ThreadId, + type UploadChatAttachment, } from "@t3tools/contracts"; import { DISCORD_LINK_REQUEST_MARKER } from "@t3tools/shared/providerModelSelection"; import { Discord, DiscordREST, Ix } from "dfx"; @@ -38,6 +39,7 @@ import { parseTopicShortName, projectTopicFromParentLookup, normalizeWorkspacePath, + resolveDiscordFollowUpDelivery, type ProjectTopicLookup, } from "../presentation/mentions.ts"; import { @@ -891,9 +893,41 @@ const make = (botConfig: DiscordBotConfig) => }), ), ); + // Server queues busy-thread follow-ups by default. Discord keeps the + // historical mid-turn **steer** default: after startTurn parks the + // message, immediately inject it via thread.queue.steer unless the + // user opted into --queue. + const followUpDelivery = resolveDiscordFollowUpDelivery(input.flags); + if (followUpDelivery === "steer" && turnAlreadyRunning) { + yield* t3 + .steerQueuedMessage({ + threadId: existing.t3ThreadId, + messageId: MessageId.make(startedTurn.messageId), + }) + .pipe( + Effect.tap(() => + Effect.logInfo("Steered mid-turn Discord follow-up into active turn", { + t3ThreadId: existing.t3ThreadId, + messageId: startedTurn.messageId, + }), + ), + Effect.catch((error) => + Effect.logWarning( + "Steer after startTurn failed; message may remain server-queued", + { + t3ThreadId: existing.t3ThreadId, + messageId: startedTurn.messageId, + error: String(error), + }, + ), + ), + ); + } yield* Effect.logInfo("startTurn dispatched", { t3ThreadId: existing.t3ThreadId, messageId: startedTurn.messageId, + followUpDelivery, + turnAlreadyRunning, modelSelection: continueModelSelection ? `${continueModelSelection.instanceId}/${continueModelSelection.model}` : "thread-sticky", @@ -1943,7 +1977,7 @@ const make = (botConfig: DiscordBotConfig) => content: content.length === 0 ? "I saw your mention but message content is empty. Enable **Message Content Intent** for this bot in the Discord Developer Portal, then restart me." - : "Send a prompt after mentioning me (or attach a file). Optional flags: `--model` `--provider` `--base` `--local` `--plan`.", + : "Send a prompt after mentioning me (or attach a file). Optional flags: `--model` `--provider` `--base` `--local` `--plan` `--steer` `--queue`. Mid-turn follow-ups steer by default; use `--queue` to park until the turn finishes.", message_reference: { message_id: event.id }, }); return; @@ -2538,12 +2572,22 @@ const make = (botConfig: DiscordBotConfig) => const base = optionString("base").trim(); const local = optionBoolean("local"); const plan = optionBoolean("plan"); + const steer = optionBoolean("steer"); + const queue = optionBoolean("queue"); + // Slash booleans: when both true, queue wins (explicit opt-out of default steer). + const followUpDelivery = + queue === true + ? ("queue" as const) + : steer === true + ? ("steer" as const) + : undefined; const flags = { ...(model.length > 0 ? { model } : {}), ...(provider.length > 0 ? { provider } : {}), ...(base.length > 0 ? { base } : {}), local, plan, + ...(followUpDelivery === undefined ? {} : { followUpDelivery }), prompt, }; @@ -2573,7 +2617,13 @@ const make = (botConfig: DiscordBotConfig) => const requester = interactionRequester(interaction); const displayName = requester.author?.displayName ?? requester.author?.username ?? "Someone"; - const ack = formatAskSlashAck({ displayName, prompt, plan, local }); + const ack = formatAskSlashAck({ + displayName, + prompt, + plan, + local, + ...(followUpDelivery === undefined ? {} : { followUpDelivery }), + }); if (inThread) { // Fire-and-forget so we ack within Discord's interaction window. diff --git a/apps/discord-bot/src/presentation/channelInfoPin.test.ts b/apps/discord-bot/src/presentation/channelInfoPin.test.ts index 44d0305369f..4719f83ca55 100644 --- a/apps/discord-bot/src/presentation/channelInfoPin.test.ts +++ b/apps/discord-bot/src/presentation/channelInfoPin.test.ts @@ -83,6 +83,7 @@ describe("channel info pin helpers", () => { expect(rendered).toContain("/omegent refresh-indicators"); expect(rendered).toContain("@Omegent …"); expect(rendered).toContain("Same actions (fallback)"); + expect(rendered).toContain("--steer (default mid-turn) --queue (park until turn ends)"); expect(rendered).toContain("Default provider/model: `codex/gpt-5.4`"); // Labels pad to the widest provider label ("grok [missing]"). expect(rendered).toContain("codex gpt-5.4, gpt-5.5"); diff --git a/apps/discord-bot/src/presentation/channelInfoPin.ts b/apps/discord-bot/src/presentation/channelInfoPin.ts index a40f8a6a0ae..62a470eeefb 100644 --- a/apps/discord-bot/src/presentation/channelInfoPin.ts +++ b/apps/discord-bot/src/presentation/channelInfoPin.ts @@ -128,7 +128,7 @@ function buildChannelInfoPinBody(input: { "Bot commands (prefer **/omegent**, alias **/agent**):", "```text", "/omegent ask prompt:… Start or continue work", - " options: model provider base local plan", + " options: model provider base local plan steer queue", "/omegent help This pin", "/omegent stop Stop active turn", "/omegent thread-talk action:on|off|status", @@ -136,6 +136,7 @@ function buildChannelInfoPinBody(input: { "/omegent refresh-indicators", "@Omegent … Same actions (fallback)", " flags: --plan --local --base --provider --model ", + " --steer (default mid-turn) --queue (park until turn ends)", "```", `Default provider/model: \`${defaultLine}\``, "Supported provider/model configurations:", diff --git a/apps/discord-bot/src/presentation/mentions.test.ts b/apps/discord-bot/src/presentation/mentions.test.ts index d82f3e0f216..1db4f91ad89 100644 --- a/apps/discord-bot/src/presentation/mentions.test.ts +++ b/apps/discord-bot/src/presentation/mentions.test.ts @@ -9,6 +9,7 @@ import { parseTopicShortName, projectTopicFromParentLookup, readChannelTopic, + resolveDiscordFollowUpDelivery, } from "./mentions.ts"; import { chunkDiscordContent, @@ -121,6 +122,22 @@ describe("parseMentionFlags", () => { prompt: "fix the flaky test", }); }); + + it("parses --steer and --queue with last-wins when both appear", () => { + expect(parseMentionFlags("--steer also check the race").followUpDelivery).toBe("steer"); + expect(parseMentionFlags("--queue park this for later").followUpDelivery).toBe("queue"); + expect(parseMentionFlags("--steer --queue prefer queue").followUpDelivery).toBe("queue"); + expect(parseMentionFlags("--queue --steer prefer steer").followUpDelivery).toBe("steer"); + expect(parseMentionFlags("plain follow-up").followUpDelivery).toBeUndefined(); + }); +}); + +describe("resolveDiscordFollowUpDelivery", () => { + it("defaults mid-turn Discord delivery to steer", () => { + expect(resolveDiscordFollowUpDelivery({})).toBe("steer"); + expect(resolveDiscordFollowUpDelivery({ followUpDelivery: "steer" })).toBe("steer"); + expect(resolveDiscordFollowUpDelivery({ followUpDelivery: "queue" })).toBe("queue"); + }); }); describe("parseMentionIntent", () => { diff --git a/apps/discord-bot/src/presentation/mentions.ts b/apps/discord-bot/src/presentation/mentions.ts index 834bef5acf6..b865db4d7b9 100644 --- a/apps/discord-bot/src/presentation/mentions.ts +++ b/apps/discord-bot/src/presentation/mentions.ts @@ -2,15 +2,36 @@ import { parseProviderModelFlags } from "@t3tools/shared/providerModelSelection" import { parseLinkThreadCommand, type LinkThreadCommand } from "./t3ThreadRef.ts"; +/** How a mid-turn Discord follow-up is delivered to the server queue. */ +export type DiscordFollowUpDelivery = "steer" | "queue"; + export interface ParsedMentionFlags { readonly model?: string; readonly provider?: string; readonly base?: string; readonly local: boolean; readonly plan: boolean; + /** + * Explicit mid-turn delivery override. When omitted, busy-thread follow-ups + * **steer** into the active turn (Discord default). `--queue` parks the + * message server-side until the turn finishes; `--steer` forces immediate + * injection. + */ + readonly followUpDelivery?: DiscordFollowUpDelivery; readonly prompt: string; } +/** + * Resolve mid-turn delivery. Discord keeps historical steer-by-default + * behavior; flags force either path. Idle threads ignore this (startTurn + * opens a normal turn). + */ +export function resolveDiscordFollowUpDelivery( + flags: Pick, +): DiscordFollowUpDelivery { + return flags.followUpDelivery ?? "steer"; +} + export type ParsedMentionIntent = | { readonly kind: "interrupt" } | { readonly kind: "help" } @@ -30,6 +51,9 @@ const REFRESH_INDICATORS_PROMPTS = new Set([ /** * Parse optional flags from a bot mention body (after stripping the mention). * Flags: --model --provider --base --local --plan + * --steer --queue + * + * `--steer` / `--queue` last-wins when both appear. */ export function parseMentionFlags(raw: string): ParsedMentionFlags { const providerModel = parseProviderModelFlags(raw); @@ -40,6 +64,7 @@ export function parseMentionFlags(raw: string): ParsedMentionFlags { let base: string | undefined; let local = false; let plan = false; + let followUpDelivery: DiscordFollowUpDelivery | undefined; const promptParts: string[] = []; for (let index = 0; index < tokens.length; index += 1) { @@ -52,6 +77,14 @@ export function parseMentionFlags(raw: string): ParsedMentionFlags { plan = true; continue; } + if (token === "--steer") { + followUpDelivery = "steer"; + continue; + } + if (token === "--queue") { + followUpDelivery = "queue"; + continue; + } if (token === "--base") { const next = tokens[index + 1]; if (next !== undefined && !next.startsWith("--")) { @@ -69,6 +102,7 @@ export function parseMentionFlags(raw: string): ParsedMentionFlags { ...(base === undefined ? {} : { base }), local, plan, + ...(followUpDelivery === undefined ? {} : { followUpDelivery }), prompt: promptParts.join(" ").trim(), }; } diff --git a/apps/discord-bot/src/presentation/slashCommands.ts b/apps/discord-bot/src/presentation/slashCommands.ts index afda8920dad..e1582d77982 100644 --- a/apps/discord-bot/src/presentation/slashCommands.ts +++ b/apps/discord-bot/src/presentation/slashCommands.ts @@ -58,6 +58,18 @@ export const OMEGENT_SLASH_COMMAND = { description: "Run in plan mode", required: false, }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "steer", + description: "Mid-turn: inject now (default when a turn is already running)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "queue", + description: "Mid-turn: park until the active turn finishes (opt out of steer)", + required: false, + }, ], }, { @@ -167,10 +179,17 @@ export function formatAskSlashAck(input: { readonly prompt: string; readonly plan: boolean; readonly local: boolean; + readonly followUpDelivery?: "steer" | "queue"; }): string { - const flags = [input.plan ? "`--plan`" : null, input.local ? "`--local`" : null].filter( - (value): value is string => value !== null, - ); + const flags = [ + input.plan ? "`--plan`" : null, + input.local ? "`--local`" : null, + input.followUpDelivery === "queue" + ? "`--queue`" + : input.followUpDelivery === "steer" + ? "`--steer`" + : null, + ].filter((value): value is string => value !== null); const flagSuffix = flags.length > 0 ? ` (${flags.join(" ")})` : ""; const preview = input.prompt.length > 280 ? `${input.prompt.slice(0, 277).trimEnd()}…` : input.prompt; diff --git a/apps/discord-bot/src/t3/T3Session.ts b/apps/discord-bot/src/t3/T3Session.ts index f047b88035f..b1c1c184367 100644 --- a/apps/discord-bot/src/t3/T3Session.ts +++ b/apps/discord-bot/src/t3/T3Session.ts @@ -142,6 +142,15 @@ export interface T3SessionService { readonly interactionMode?: ProviderInteractionMode; readonly attachments?: ReadonlyArray; }) => Effect.Effect<{ readonly messageId: string }, T3SessionError>; + /** + * Inject a server-queued follow-up into the active turn (or start a turn if + * idle). Used after `startTurn` queues a mid-turn Discord message so the bot + * keeps historical steer-by-default behavior. + */ + readonly steerQueuedMessage: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect; /** * Subscribe to thread events until disconnect/interrupt. * Runs `onThread` in the caller fiber context (must not be forked onto a bare T3 runtime). @@ -756,6 +765,14 @@ export const makeT3Session = (botConfig: DiscordBotConfig) => }); return { messageId }; }), + steerQueuedMessage: (input) => + dispatch({ + type: "thread.queue.steer", + commandId: newCommandId(), + threadId: input.threadId, + messageId: input.messageId, + createdAt: nowIso(), + }), /** * Subscribe to thread events and run `onThread` in the **caller's** Effect context. * From 2cfa398fd1cb2eba088d23fa882a11e2ae28ab96 Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Sat, 25 Jul 2026 21:44:27 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(discord-bot):=20queue=20mid-turn=20by?= =?UTF-8?q?=20default=20with=20=F0=9F=93=A5,=20delete,=20steernow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep --steer/--queue and add slash /steer /queue /steernow. Mid-turn follow-ups park server-side by default, badge the user message with 📥, remove on message delete, and flush the queue with /omegent steernow. --- .../DiscordQueuedPromptRegistry.test.ts | 62 ++++++ .../features/DiscordQueuedPromptRegistry.ts | 93 +++++++++ .../discord-bot/src/features/MentionRouter.ts | 194 +++++++++++++++++- .../src/presentation/channelInfoPin.test.ts | 4 +- .../src/presentation/channelInfoPin.ts | 6 +- .../src/presentation/mentions.test.ts | 4 +- apps/discord-bot/src/presentation/mentions.ts | 11 +- .../src/presentation/slashCommands.test.ts | 12 +- .../src/presentation/slashCommands.ts | 71 ++++++- apps/discord-bot/src/t3/T3Session.ts | 12 ++ 10 files changed, 445 insertions(+), 24 deletions(-) create mode 100644 apps/discord-bot/src/features/DiscordQueuedPromptRegistry.test.ts create mode 100644 apps/discord-bot/src/features/DiscordQueuedPromptRegistry.ts diff --git a/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.test.ts b/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.test.ts new file mode 100644 index 00000000000..c68b4ffc573 --- /dev/null +++ b/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.test.ts @@ -0,0 +1,62 @@ +import { MessageId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { createDiscordQueuedPromptRegistry } from "./DiscordQueuedPromptRegistry.ts"; + +describe("DiscordQueuedPromptRegistry", () => { + it("remembers entries by Discord message and thread", () => { + const registry = createDiscordQueuedPromptRegistry(); + const entry = { + discordChannelId: "channel-1", + discordMessageId: "discord-1", + t3ThreadId: ThreadId.make("thread-1"), + t3MessageId: MessageId.make("msg-1"), + authorUserId: "user-1", + }; + registry.remember(entry); + expect(registry.getByDiscordMessageId("discord-1")).toEqual(entry); + expect(registry.listForThread(ThreadId.make("thread-1"))).toEqual([entry]); + }); + + it("forgets by Discord message id and by T3 message id", () => { + const registry = createDiscordQueuedPromptRegistry(); + registry.remember({ + discordChannelId: "channel-1", + discordMessageId: "discord-1", + t3ThreadId: ThreadId.make("thread-1"), + t3MessageId: MessageId.make("msg-1"), + authorUserId: null, + }); + registry.remember({ + discordChannelId: "channel-1", + discordMessageId: "discord-2", + t3ThreadId: ThreadId.make("thread-1"), + t3MessageId: MessageId.make("msg-2"), + authorUserId: null, + }); + + expect(registry.forgetDiscordMessage("discord-1")?.t3MessageId).toBe("msg-1"); + expect(registry.getByDiscordMessageId("discord-1")).toBeNull(); + expect(registry.listForThread(ThreadId.make("thread-1"))).toHaveLength(1); + + expect( + registry.forgetT3Message(ThreadId.make("thread-1"), MessageId.make("msg-2")) + ?.discordMessageId, + ).toBe("discord-2"); + expect(registry.listForThread(ThreadId.make("thread-1"))).toEqual([]); + }); + + it("clears an entire thread", () => { + const registry = createDiscordQueuedPromptRegistry(); + registry.remember({ + discordChannelId: "channel-1", + discordMessageId: "discord-1", + t3ThreadId: ThreadId.make("thread-1"), + t3MessageId: MessageId.make("msg-1"), + authorUserId: null, + }); + const cleared = registry.clearThread(ThreadId.make("thread-1")); + expect(cleared).toHaveLength(1); + expect(registry.listForThread(ThreadId.make("thread-1"))).toEqual([]); + }); +}); diff --git a/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.ts b/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.ts new file mode 100644 index 00000000000..10132ac1f6c --- /dev/null +++ b/apps/discord-bot/src/features/DiscordQueuedPromptRegistry.ts @@ -0,0 +1,93 @@ +import type { MessageId, ThreadId } from "@t3tools/contracts"; + +/** + * Tracks Discord user messages that are parked in the server follow-up queue so + * we can badge them (📥), remove on delete, and flush via /omegent steernow. + * + * In-memory only: process restart drops badges (server queue remains authoritative). + */ +export type PendingQueuedPrompt = { + readonly discordChannelId: string; + readonly discordMessageId: string; + readonly t3ThreadId: ThreadId; + readonly t3MessageId: MessageId; + readonly authorUserId: string | null; +}; + +/** Discord unicode used as the "queued" badge on the user's message. */ +export const QUEUED_PROMPT_REACTION_EMOJI = "📥"; + +export function createDiscordQueuedPromptRegistry() { + const byDiscordMessageId = new Map(); + const byT3ThreadId = new Map>(); + + const remember = (entry: PendingQueuedPrompt): void => { + byDiscordMessageId.set(entry.discordMessageId, entry); + const key = String(entry.t3ThreadId); + const set = byT3ThreadId.get(key) ?? new Set(); + set.add(entry.discordMessageId); + byT3ThreadId.set(key, set); + }; + + const forgetDiscordMessage = (discordMessageId: string): PendingQueuedPrompt | null => { + const entry = byDiscordMessageId.get(discordMessageId); + if (entry === undefined) return null; + byDiscordMessageId.delete(discordMessageId); + const key = String(entry.t3ThreadId); + const set = byT3ThreadId.get(key); + if (set !== undefined) { + set.delete(discordMessageId); + if (set.size === 0) byT3ThreadId.delete(key); + } + return entry; + }; + + const forgetT3Message = ( + t3ThreadId: ThreadId, + t3MessageId: MessageId, + ): PendingQueuedPrompt | null => { + const key = String(t3ThreadId); + const set = byT3ThreadId.get(key); + if (set === undefined) return null; + for (const discordMessageId of set) { + const entry = byDiscordMessageId.get(discordMessageId); + if (entry !== undefined && entry.t3MessageId === t3MessageId) { + return forgetDiscordMessage(discordMessageId); + } + } + return null; + }; + + const listForThread = (t3ThreadId: ThreadId): ReadonlyArray => { + const set = byT3ThreadId.get(String(t3ThreadId)); + if (set === undefined) return []; + const out: PendingQueuedPrompt[] = []; + for (const discordMessageId of set) { + const entry = byDiscordMessageId.get(discordMessageId); + if (entry !== undefined) out.push(entry); + } + return out; + }; + + const getByDiscordMessageId = (discordMessageId: string): PendingQueuedPrompt | null => + byDiscordMessageId.get(discordMessageId) ?? null; + + const clearThread = (t3ThreadId: ThreadId): ReadonlyArray => { + const listed = listForThread(t3ThreadId); + for (const entry of listed) { + forgetDiscordMessage(entry.discordMessageId); + } + return listed; + }; + + return { + remember, + forgetDiscordMessage, + forgetT3Message, + listForThread, + getByDiscordMessageId, + clearThread, + }; +} + +export type DiscordQueuedPromptRegistry = ReturnType; diff --git a/apps/discord-bot/src/features/MentionRouter.ts b/apps/discord-bot/src/features/MentionRouter.ts index d19fcf7eb87..7ff754c4176 100644 --- a/apps/discord-bot/src/features/MentionRouter.ts +++ b/apps/discord-bot/src/features/MentionRouter.ts @@ -79,6 +79,10 @@ import { T3Session, T3SessionError } from "../t3/T3Session.ts"; import { formatAlertCause } from "./Alerts.ts"; import { ensureChannelInfoPin } from "./ChannelInfoPin.ts"; import { makeDiscordThreadTurnCoordinator } from "./DiscordThreadTurnCoordinator.ts"; +import { + createDiscordQueuedPromptRegistry, + QUEUED_PROMPT_REACTION_EMOJI, +} from "./DiscordQueuedPromptRegistry.ts"; import { BridgeHub } from "./BridgeHub.ts"; import { bridgeThreadToDiscord, getLiveDiscordBridge } from "./ResponseBridge.ts"; import { upsertThreadInfoPin } from "./ThreadInfoPin.ts"; @@ -458,6 +462,44 @@ const make = (botConfig: DiscordBotConfig) => const registry = yield* InteractionsRegistry; const bridgeHub = yield* BridgeHub; const turnCoordinator = yield* makeDiscordThreadTurnCoordinator; + const queuedPrompts = createDiscordQueuedPromptRegistry(); + + const markDiscordPromptQueued = (input: { + readonly discordChannelId: string; + readonly discordMessageId: string; + readonly t3ThreadId: ThreadId; + readonly t3MessageId: MessageId; + readonly authorUserId: string | null; + }) => + Effect.gen(function* () { + queuedPrompts.remember(input); + yield* rest + .addMyMessageReaction( + input.discordChannelId, + input.discordMessageId, + QUEUED_PROMPT_REACTION_EMOJI, + ) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to add queued badge reaction", { + discordMessageId: input.discordMessageId, + error: String(error), + }), + ), + ); + }); + + const clearQueuedBadge = (entry: { + readonly discordChannelId: string; + readonly discordMessageId: string; + }) => + rest + .deleteMyMessageReaction( + entry.discordChannelId, + entry.discordMessageId, + QUEUED_PROMPT_REACTION_EMOJI, + ) + .pipe(Effect.catch(() => Effect.void)); // Mentions use the bot *user* id, not the application id. const me = yield* rest.getMyUser(); @@ -893,16 +935,16 @@ const make = (botConfig: DiscordBotConfig) => }), ), ); - // Server queues busy-thread follow-ups by default. Discord keeps the - // historical mid-turn **steer** default: after startTurn parks the - // message, immediately inject it via thread.queue.steer unless the - // user opted into --queue. + // Server parks busy-thread follow-ups. Default Discord policy is **queue** + // (badge with 📥; delete user message to remove; /omegent steernow to flush). + // `--steer` / `/omegent steer` inject immediately after startTurn. const followUpDelivery = resolveDiscordFollowUpDelivery(input.flags); + const t3MessageId = MessageId.make(startedTurn.messageId); if (followUpDelivery === "steer" && turnAlreadyRunning) { yield* t3 .steerQueuedMessage({ threadId: existing.t3ThreadId, - messageId: MessageId.make(startedTurn.messageId), + messageId: t3MessageId, }) .pipe( Effect.tap(() => @@ -922,6 +964,22 @@ const make = (botConfig: DiscordBotConfig) => ), ), ); + } else if (followUpDelivery === "queue" && turnAlreadyRunning) { + const discordMessageId = + typeof input.mentionMessage?.id === "string" ? input.mentionMessage.id : null; + if (discordMessageId !== null) { + const authorUserId = + typeof input.mentionMessage?.author?.id === "string" + ? input.mentionMessage.author.id + : null; + yield* markDiscordPromptQueued({ + discordChannelId: input.discordThreadId, + discordMessageId, + t3ThreadId: existing.t3ThreadId, + t3MessageId, + authorUserId, + }); + } } yield* Effect.logInfo("startTurn dispatched", { t3ThreadId: existing.t3ThreadId, @@ -1977,7 +2035,7 @@ const make = (botConfig: DiscordBotConfig) => content: content.length === 0 ? "I saw your mention but message content is empty. Enable **Message Content Intent** for this bot in the Discord Developer Portal, then restart me." - : "Send a prompt after mentioning me (or attach a file). Optional flags: `--model` `--provider` `--base` `--local` `--plan` `--steer` `--queue`. Mid-turn follow-ups steer by default; use `--queue` to park until the turn finishes.", + : "Send a prompt after mentioning me (or attach a file). Optional flags: `--model` `--provider` `--base` `--local` `--plan` `--steer` `--queue`. Mid-turn follow-ups **queue** by default (📥); delete your message to cancel, or `/omegent steernow` to inject the queue. Use `--steer` to inject immediately.", message_reference: { message_id: event.id }, }); return; @@ -2287,6 +2345,41 @@ const make = (botConfig: DiscordBotConfig) => const handleMessageUpdates = gateway.handleDispatch("MESSAGE_UPDATE", (event) => handleInboundMessage(event as GatewayMessageEvent, "update"), ); + // User deletes their parked prompt → drop it from the server queue (and badge). + const handleMessageDeletes = gateway.handleDispatch("MESSAGE_DELETE", (event) => + Effect.gen(function* () { + const messageId = + typeof event === "object" && + event !== null && + "id" in event && + typeof (event as { id?: unknown }).id === "string" + ? (event as { id: string }).id + : null; + if (messageId === null) return; + const pending = queuedPrompts.forgetDiscordMessage(messageId); + if (pending === null) return; + yield* Effect.logInfo("Discord user deleted queued prompt; removing from server queue", { + discordMessageId: messageId, + t3ThreadId: pending.t3ThreadId, + t3MessageId: pending.t3MessageId, + }); + yield* t3 + .removeQueuedMessage({ + threadId: pending.t3ThreadId, + messageId: pending.t3MessageId, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to remove queued prompt after Discord delete", { + t3ThreadId: pending.t3ThreadId, + t3MessageId: pending.t3MessageId, + error: String(error), + }), + ), + ); + // Message is gone — reaction cleanup is unnecessary. + }).pipe(Effect.catchCause(Effect.logError)), + ); const approvalButton = Ix.messageComponent( Ix.idStartsWith("t3_approve:"), @@ -2552,8 +2645,8 @@ const make = (botConfig: DiscordBotConfig) => }; }; - return ix.subCommands({ - ask: Effect.gen(function* () { + const promptSlashHandler = (forcedDelivery?: "steer" | "queue") => + Effect.gen(function* () { const interaction = yield* Ix.Interaction; const channelId = interaction.channel_id; if (channelId === undefined || channelId.length === 0) { @@ -2574,13 +2667,14 @@ const make = (botConfig: DiscordBotConfig) => const plan = optionBoolean("plan"); const steer = optionBoolean("steer"); const queue = optionBoolean("queue"); - // Slash booleans: when both true, queue wins (explicit opt-out of default steer). + // Subcommand force wins; otherwise ask booleans (queue wins if both true). const followUpDelivery = - queue === true + forcedDelivery ?? + (queue === true ? ("queue" as const) : steer === true ? ("steer" as const) - : undefined; + : undefined); const flags = { ...(model.length > 0 ? { model } : {}), ...(provider.length > 0 ? { provider } : {}), @@ -2713,6 +2807,83 @@ const make = (botConfig: DiscordBotConfig) => ), ), ), + ); + + return ix.subCommands({ + ask: promptSlashHandler(), + steer: promptSlashHandler("steer"), + queue: promptSlashHandler("queue"), + + steernow: Effect.gen(function* () { + const interaction = yield* Ix.Interaction; + const channelId = interaction.channel_id; + if (channelId === undefined || channelId.length === 0) { + return slashReply("steernow only works inside a linked Discord thread.", { + ephemeral: true, + }); + } + const channel = yield* rest.getChannel(channelId); + if (!isThreadChannel(channel.type)) { + return slashReply("steernow only works inside a linked Discord thread.", { + ephemeral: true, + }); + } + const link = yield* links.getByDiscordThreadId(channelId); + if (link === null) { + return slashReply("This thread is not linked to an Omegent session.", { + ephemeral: true, + }); + } + + const detail = yield* t3.fetchThreadDetail(link.t3ThreadId); + const queued = detail?.thread.queuedMessages ?? []; + if (queued.length === 0) { + return slashReply("Nothing is queued on this thread.", { ephemeral: true }); + } + + let steered = 0; + const failures: string[] = []; + for (const message of queued) { + const result = yield* t3 + .steerQueuedMessage({ + threadId: link.t3ThreadId, + messageId: message.messageId, + }) + .pipe(Effect.result); + if (Result.isSuccess(result)) { + steered += 1; + const pending = queuedPrompts.forgetT3Message(link.t3ThreadId, message.messageId); + if (pending !== null) { + yield* clearQueuedBadge(pending); + } + } else { + failures.push(String(message.messageId)); + yield* Effect.logWarning("steernow failed for queued message", { + t3ThreadId: link.t3ThreadId, + messageId: message.messageId, + error: String(result.failure), + }); + } + } + + if (steered === 0) { + return slashReply( + `Could not steer the queue (${failures.length} failed). Try again when the turn is running.`, + { ephemeral: true }, + ); + } + const failNote = + failures.length > 0 ? ` (${failures.length} could not be steered yet)` : ""; + return slashReply(`Steered ${steered} queued message(s)${failNote}.`); + }).pipe( + Effect.catch((error: unknown) => + Effect.succeed( + slashReply( + `steernow failed: ${error instanceof Error ? error.message : String(error)}`, + { ephemeral: true }, + ), + ), + ), ), help: Effect.gen(function* () { @@ -3084,6 +3255,7 @@ const make = (botConfig: DiscordBotConfig) => ); yield* Effect.forkScoped(handleMessages); yield* Effect.forkScoped(handleMessageUpdates); + yield* Effect.forkScoped(handleMessageDeletes); yield* Effect.logInfo("Discord mention router ready"); return DiscordBotRunning.of({ botUserId }); }); diff --git a/apps/discord-bot/src/presentation/channelInfoPin.test.ts b/apps/discord-bot/src/presentation/channelInfoPin.test.ts index 4719f83ca55..5596b8195eb 100644 --- a/apps/discord-bot/src/presentation/channelInfoPin.test.ts +++ b/apps/discord-bot/src/presentation/channelInfoPin.test.ts @@ -83,7 +83,9 @@ describe("channel info pin helpers", () => { expect(rendered).toContain("/omegent refresh-indicators"); expect(rendered).toContain("@Omegent …"); expect(rendered).toContain("Same actions (fallback)"); - expect(rendered).toContain("--steer (default mid-turn) --queue (park until turn ends)"); + expect(rendered).toContain("/omegent steernow"); + expect(rendered).toContain("--steer (inject now) --queue (park; default mid-turn)"); + expect(rendered).toContain("delete your message to cancel"); expect(rendered).toContain("Default provider/model: `codex/gpt-5.4`"); // Labels pad to the widest provider label ("grok [missing]"). expect(rendered).toContain("codex gpt-5.4, gpt-5.5"); diff --git a/apps/discord-bot/src/presentation/channelInfoPin.ts b/apps/discord-bot/src/presentation/channelInfoPin.ts index 62a470eeefb..88f11b24a36 100644 --- a/apps/discord-bot/src/presentation/channelInfoPin.ts +++ b/apps/discord-bot/src/presentation/channelInfoPin.ts @@ -129,6 +129,9 @@ function buildChannelInfoPinBody(input: { "```text", "/omegent ask prompt:… Start or continue work", " options: model provider base local plan steer queue", + "/omegent steer prompt:… Mid-turn: inject now", + "/omegent queue prompt:… Mid-turn: park (same as default)", + "/omegent steernow Inject the whole parked queue", "/omegent help This pin", "/omegent stop Stop active turn", "/omegent thread-talk action:on|off|status", @@ -136,7 +139,8 @@ function buildChannelInfoPinBody(input: { "/omegent refresh-indicators", "@Omegent … Same actions (fallback)", " flags: --plan --local --base --provider --model ", - " --steer (default mid-turn) --queue (park until turn ends)", + " --steer (inject now) --queue (park; default mid-turn)", + " queued: 📥 badge · delete your message to cancel · /steernow to flush", "```", `Default provider/model: \`${defaultLine}\``, "Supported provider/model configurations:", diff --git a/apps/discord-bot/src/presentation/mentions.test.ts b/apps/discord-bot/src/presentation/mentions.test.ts index 1db4f91ad89..5df28055fdd 100644 --- a/apps/discord-bot/src/presentation/mentions.test.ts +++ b/apps/discord-bot/src/presentation/mentions.test.ts @@ -133,8 +133,8 @@ describe("parseMentionFlags", () => { }); describe("resolveDiscordFollowUpDelivery", () => { - it("defaults mid-turn Discord delivery to steer", () => { - expect(resolveDiscordFollowUpDelivery({})).toBe("steer"); + it("defaults mid-turn Discord delivery to queue", () => { + expect(resolveDiscordFollowUpDelivery({})).toBe("queue"); expect(resolveDiscordFollowUpDelivery({ followUpDelivery: "steer" })).toBe("steer"); expect(resolveDiscordFollowUpDelivery({ followUpDelivery: "queue" })).toBe("queue"); }); diff --git a/apps/discord-bot/src/presentation/mentions.ts b/apps/discord-bot/src/presentation/mentions.ts index b865db4d7b9..74e4d3b29a5 100644 --- a/apps/discord-bot/src/presentation/mentions.ts +++ b/apps/discord-bot/src/presentation/mentions.ts @@ -13,23 +13,22 @@ export interface ParsedMentionFlags { readonly plan: boolean; /** * Explicit mid-turn delivery override. When omitted, busy-thread follow-ups - * **steer** into the active turn (Discord default). `--queue` parks the - * message server-side until the turn finishes; `--steer` forces immediate - * injection. + * **queue** server-side (platform default) and are badged with 📥. + * `--steer` injects into the active turn immediately; `--queue` forces park. */ readonly followUpDelivery?: DiscordFollowUpDelivery; readonly prompt: string; } /** - * Resolve mid-turn delivery. Discord keeps historical steer-by-default - * behavior; flags force either path. Idle threads ignore this (startTurn + * Resolve mid-turn delivery. Default is **queue** (server-aligned); `--steer` + * / `/omegent steer` force injection. Idle threads ignore this (startTurn * opens a normal turn). */ export function resolveDiscordFollowUpDelivery( flags: Pick, ): DiscordFollowUpDelivery { - return flags.followUpDelivery ?? "steer"; + return flags.followUpDelivery ?? "queue"; } export type ParsedMentionIntent = diff --git a/apps/discord-bot/src/presentation/slashCommands.test.ts b/apps/discord-bot/src/presentation/slashCommands.test.ts index 1df67315613..320a8df7d47 100644 --- a/apps/discord-bot/src/presentation/slashCommands.test.ts +++ b/apps/discord-bot/src/presentation/slashCommands.test.ts @@ -18,7 +18,17 @@ describe("Omegent slash command definition", () => { expect(OMEGENT_SLASH_COMMAND_ALIAS).toBe("agent"); expect(OMEGENT_SLASH_COMMAND.name).toBe("omegent"); const names = OMEGENT_SLASH_COMMAND.options.map((option) => option.name); - expect(names).toEqual(["ask", "help", "stop", "thread-talk", "link", "refresh-indicators"]); + expect(names).toEqual([ + "ask", + "steer", + "queue", + "steernow", + "help", + "stop", + "thread-talk", + "link", + "refresh-indicators", + ]); }); it("uses Discord subcommand option types", () => { diff --git a/apps/discord-bot/src/presentation/slashCommands.ts b/apps/discord-bot/src/presentation/slashCommands.ts index e1582d77982..03b42354599 100644 --- a/apps/discord-bot/src/presentation/slashCommands.ts +++ b/apps/discord-bot/src/presentation/slashCommands.ts @@ -61,17 +61,84 @@ export const OMEGENT_SLASH_COMMAND = { { type: Discord.ApplicationCommandOptionType.BOOLEAN, name: "steer", - description: "Mid-turn: inject now (default when a turn is already running)", + description: "Mid-turn: inject this prompt now (default is queue)", required: false, }, { type: Discord.ApplicationCommandOptionType.BOOLEAN, name: "queue", - description: "Mid-turn: park until the active turn finishes (opt out of steer)", + description: "Mid-turn: park until the turn finishes (default)", required: false, }, ], }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "steer", + description: "Continue work and inject mid-turn (steer into the active turn)", + options: [ + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "prompt", + description: "What you want Omegent to do", + required: true, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "model", + description: "Model slug (e.g. gpt-5.4)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "provider", + description: "Provider instance id (e.g. codex)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "plan", + description: "Run in plan mode", + required: false, + }, + ], + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "queue", + description: "Continue work and park mid-turn until the active turn finishes", + options: [ + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "prompt", + description: "What you want Omegent to do", + required: true, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "model", + description: "Model slug (e.g. gpt-5.4)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.STRING, + name: "provider", + description: "Provider instance id (e.g. codex)", + required: false, + }, + { + type: Discord.ApplicationCommandOptionType.BOOLEAN, + name: "plan", + description: "Run in plan mode", + required: false, + }, + ], + }, + { + type: Discord.ApplicationCommandOptionType.SUB_COMMAND, + name: "steernow", + description: "Inject every parked follow-up into the active turn (FIFO)", + }, { type: Discord.ApplicationCommandOptionType.SUB_COMMAND, name: "help", diff --git a/apps/discord-bot/src/t3/T3Session.ts b/apps/discord-bot/src/t3/T3Session.ts index b1c1c184367..63da3ec240b 100644 --- a/apps/discord-bot/src/t3/T3Session.ts +++ b/apps/discord-bot/src/t3/T3Session.ts @@ -151,6 +151,10 @@ export interface T3SessionService { readonly threadId: ThreadId; readonly messageId: MessageId; }) => Effect.Effect; + readonly removeQueuedMessage: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect; /** * Subscribe to thread events until disconnect/interrupt. * Runs `onThread` in the caller fiber context (must not be forked onto a bare T3 runtime). @@ -773,6 +777,14 @@ export const makeT3Session = (botConfig: DiscordBotConfig) => messageId: input.messageId, createdAt: nowIso(), }), + removeQueuedMessage: (input) => + dispatch({ + type: "thread.queue.remove", + commandId: newCommandId(), + threadId: input.threadId, + messageId: input.messageId, + createdAt: nowIso(), + }), /** * Subscribe to thread events and run `onThread` in the **caller's** Effect context. *