From b21628c0f68fe8031dd1e5f09b9295d832006fb8 Mon Sep 17 00:00:00 2001 From: Kai Liu Date: Sat, 18 Apr 2026 14:01:47 +0800 Subject: [PATCH] Auto-render Slack ask_user questions as buttons when options are simple Moves the "render buttons vs plain text" decision out of the system prompt and into the Slack IM adapter. Ode now inspects each question's options and posts an interactive actions block whenever they qualify as simple (2-5 labels, each <= 15 chars, no newlines), falling back to "Options: a / b / c" text otherwise. Both the SDK-emitted question.asked flow and the LLM-invoked ask_user action share the same renderer, so behavior is consistent. --- packages/agents/shared.ts | 7 -- packages/core/kernel/pending-question.ts | 15 +++- packages/core/kernel/request-run.ts | 16 +++- packages/core/runtime/helpers.ts | 18 ++++ packages/core/test/pending-question.test.ts | 74 ++++++++++++++++ packages/core/test/runtime-helpers.test.ts | 32 +++++++ packages/core/types.ts | 14 +++ packages/ims/slack/api.ts | 96 +++++++++++++++------ packages/ims/slack/client.ts | 24 ++++++ 9 files changed, 261 insertions(+), 35 deletions(-) diff --git a/packages/agents/shared.ts b/packages/agents/shared.ts index 750a067..15dc646 100644 --- a/packages/agents/shared.ts +++ b/packages/agents/shared.ts @@ -67,13 +67,6 @@ export function buildSystemPrompt(slack?: SlackContext): string { lines.push("- You can use any tool available via bash, curl"); lines.push(""); lines.push(`IMPORTANT: Your text output is automatically posted to ${platformLabel}.`); - lines.push( - platform === "discord" - ? "- When asking the user to choose options, use ask_user action and do NOT also output text - the posted question is enough." - : platform === "lark" - ? "- When asking the user to choose options, use ask_user action and do NOT also output text - the posted question is enough." - : "- When asking the user to choose options, you can send an ask_user Slack action, do NOT also output text - the buttons are enough." - ); lines.push("- Only output text OR use a messaging tool, never both."); lines.push(""); lines.push("FORMATTING:"); diff --git a/packages/core/kernel/pending-question.ts b/packages/core/kernel/pending-question.ts index 994a0ec..400ecdd 100644 --- a/packages/core/kernel/pending-question.ts +++ b/packages/core/kernel/pending-question.ts @@ -82,8 +82,19 @@ export async function handlePendingQuestionReply(params: { try { const question = pendingQuestion.questions[nextIndex]; if (question) { - const nextPrompt = formatSingleQuestionPrompt(question, nextIndex, totalQuestions); - await deps.im.sendMessage(context.channelId, context.replyThreadId, nextPrompt); + const prefix = totalQuestions > 1 ? `(${nextIndex + 1}/${totalQuestions}) ` : ""; + if (typeof deps.im.sendQuestion === "function") { + await deps.im.sendQuestion( + context.channelId, + context.replyThreadId, + question.question, + question.options, + prefix + ); + } else { + const nextPrompt = formatSingleQuestionPrompt(question, nextIndex, totalQuestions); + await deps.im.sendMessage(context.channelId, context.replyThreadId, nextPrompt); + } } } catch (err) { log.warn("Failed to send follow-up question", { diff --git a/packages/core/kernel/request-run.ts b/packages/core/kernel/request-run.ts index 5b62a08..6e8295d 100644 --- a/packages/core/kernel/request-run.ts +++ b/packages/core/kernel/request-run.ts @@ -429,8 +429,20 @@ async function startKernelEventStreamWatcher(params: { void (async () => { try { - const promptText = formatSingleQuestionPrompt(normalized[0]!, 0, normalized.length); - await deps.im.sendMessage(request.channelId, request.replyThreadId, promptText); + const first = normalized[0]!; + const prefix = normalized.length > 1 ? `(1/${normalized.length}) ` : ""; + if (typeof deps.im.sendQuestion === "function") { + await deps.im.sendQuestion( + request.channelId, + request.replyThreadId, + first.question, + first.options, + prefix + ); + } else { + const promptText = formatSingleQuestionPrompt(first, 0, normalized.length); + await deps.im.sendMessage(request.channelId, request.replyThreadId, promptText); + } } catch (err) { log.warn("Failed to post ask_user question", { channelId: request.channelId, diff --git a/packages/core/runtime/helpers.ts b/packages/core/runtime/helpers.ts index 9261048..2a25e47 100644 --- a/packages/core/runtime/helpers.ts +++ b/packages/core/runtime/helpers.ts @@ -115,3 +115,21 @@ export function formatSingleQuestionPrompt( export function buildQuestionAnswers(answers: string[]): Array> { return answers.map((answer) => [answer]); } + +/** + * Heuristic for rendering a question's options as interactive UI (e.g. Slack + * buttons) rather than plain "a / b / c" text. Conservative so we only promote + * to buttons when labels are short enough to fit comfortably and the count is + * within Slack's actions-block comfort zone. + */ +export function hasSimpleOptions(options: readonly string[] | undefined): boolean { + if (!options) return false; + if (options.length < 2 || options.length > 5) return false; + for (const opt of options) { + const trimmed = opt?.trim?.(); + if (!trimmed) return false; + if (trimmed.length > 15) return false; + if (/[\r\n]/.test(trimmed)) return false; + } + return true; +} diff --git a/packages/core/test/pending-question.test.ts b/packages/core/test/pending-question.test.ts index 05689f5..2a80868 100644 --- a/packages/core/test/pending-question.test.ts +++ b/packages/core/test/pending-question.test.ts @@ -146,6 +146,80 @@ describe("handlePendingQuestionReply", () => { deleteSession(channelId, threadId); }); + it("uses sendQuestion for follow-up questions when the IM supports it", async () => { + const channelId = "CQ-PENDING-SENDQ"; + const threadId = "TQ-PENDING-SENDQ"; + const userId = "U-OWNER-SENDQ"; + const pending: PendingQuestion = { + requestId: "req-sendq", + sessionId: "ses-sendq", + askedAt: Date.now(), + questions: [ + { question: "Q1" }, + { question: "Q2", options: ["yes", "no"] }, + ], + collectedAnswers: [], + }; + + saveSession({ + sessionId: "ses-sendq", + channelId, + threadId, + workingDirectory: "/tmp", + threadOwnerUserId: userId, + createdAt: Date.now(), + lastActivityAt: Date.now(), + pendingQuestion: pending, + }); + setPendingQuestion(channelId, threadId, pending); + + const sendQuestionCalls: Array<{ + question: string; + options?: string[]; + prefix?: string; + }> = []; + const sendMessageCalls: string[] = []; + const deps = { + agent: { replyToQuestion: async () => {} } as any, + im: { + sendMessage: async (_c: string, _t: string, text: string) => { + sendMessageCalls.push(text); + return undefined; + }, + sendQuestion: async ( + _c: string, + _t: string, + question: string, + options: string[] | undefined, + prefix?: string + ) => { + sendQuestionCalls.push({ question, options, prefix }); + return undefined; + }, + } as any, + }; + + await handlePendingQuestionReply({ + deps, + pendingQuestion: pending, + context: { + channelId, + replyThreadId: threadId, + threadId, + userId, + messageId: "m-sendq-1", + }, + text: "answer 1", + }); + + expect(sendQuestionCalls).toEqual([ + { question: "Q2", options: ["yes", "no"], prefix: "(2/2) " }, + ]); + expect(sendMessageCalls).toEqual([]); + + deleteSession(channelId, threadId); + }); + it("ignores non-owner replies", async () => { const channelId = "CQ-PENDING-2"; const threadId = "TQ-PENDING-2"; diff --git a/packages/core/test/runtime-helpers.test.ts b/packages/core/test/runtime-helpers.test.ts index 4659511..443f555 100644 --- a/packages/core/test/runtime-helpers.test.ts +++ b/packages/core/test/runtime-helpers.test.ts @@ -5,6 +5,7 @@ import { categorizeRuntimeError, formatQuestionPrompt, formatSingleQuestionPrompt, + hasSimpleOptions, } from "../runtime/helpers"; describe("runtime helpers", () => { @@ -74,4 +75,35 @@ describe("runtime helpers", () => { const result = categorizeRuntimeError(new Error("Codex CLI timed out")); expect(result.message).toBe("Request timed out"); }); + + describe("hasSimpleOptions", () => { + it("accepts 2-5 short options", () => { + expect(hasSimpleOptions(["yes", "no"])).toBe(true); + expect(hasSimpleOptions(["a", "b", "c", "d", "e"])).toBe(true); + }); + + it("rejects fewer than 2 or more than 5 options", () => { + expect(hasSimpleOptions(["only"])).toBe(false); + expect(hasSimpleOptions(["a", "b", "c", "d", "e", "f"])).toBe(false); + expect(hasSimpleOptions(undefined)).toBe(false); + expect(hasSimpleOptions([])).toBe(false); + }); + + it("rejects labels longer than 15 characters", () => { + expect(hasSimpleOptions(["short", "this label is definitely way too long"])).toBe(false); + // 15 is allowed; 16 is not. + expect(hasSimpleOptions(["abcdefghijklmno", "ok"])).toBe(true); + expect(hasSimpleOptions(["abcdefghijklmnop", "ok"])).toBe(false); + }); + + it("rejects labels containing newlines", () => { + expect(hasSimpleOptions(["yes", "no\nmaybe"])).toBe(false); + expect(hasSimpleOptions(["yes", "no\r\nmaybe"])).toBe(false); + }); + + it("rejects empty/whitespace-only labels", () => { + expect(hasSimpleOptions(["yes", ""])).toBe(false); + expect(hasSimpleOptions(["yes", " "])).toBe(false); + }); + }); }); diff --git a/packages/core/types.ts b/packages/core/types.ts index 7155002..9d212b4 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -44,6 +44,20 @@ export type AgentStatusMessageParams = { export interface IMAdapter { maxEditableMessageChars?: number; sendMessage(channelId: string, threadId: string, text: string): Promise; + /** + * Optional. When present, the runtime calls this for ask_user-style prompts + * so the IM can render interactive UI (e.g. Slack buttons) when the options + * are simple enough. Implementations are free to fall back to plain text. + * `prefix` is an optional leading marker like "(1/2) " for multi-question + * flows. + */ + sendQuestion?( + channelId: string, + threadId: string, + question: string, + options: string[] | undefined, + prefix?: string + ): Promise; updateMessage( channelId: string, messageTs: string, diff --git a/packages/ims/slack/api.ts b/packages/ims/slack/api.ts index 7b1894f..f9e3306 100644 --- a/packages/ims/slack/api.ts +++ b/packages/ims/slack/api.ts @@ -1,5 +1,6 @@ import { basename } from "path"; import { getApp, getSlackBotToken } from "./client"; +import { hasSimpleOptions } from "@/core/runtime/helpers"; export type SlackActionName = | "get_thread_messages" @@ -70,6 +71,70 @@ function normalizeOptionLabel(option: unknown): string { return String(option ?? ""); } +/** + * Post a question to Slack. When the options are "simple" (2-5 short labels + * with no newlines) we render interactive buttons via an `actions` block so + * the user can tap a choice. Otherwise — including when there are no options + * at all — we fall back to a plain text message listing the choices inline. + * + * Shared by `ask_user` (LLM action) and the runtime's `sendQuestion` path + * (SDK-emitted `question` events) so both render consistently. + */ +export async function postSlackQuestion(args: { + channelId: string; + threadId: string; + question: string; + options?: string[]; + prefix?: string; + token: string; +}): Promise { + const { channelId, threadId, question, prefix, token } = args; + const client = getApp().client; + const options = (args.options ?? []) + .map((opt) => (typeof opt === "string" ? opt : normalizeOptionLabel(opt))) + .filter((opt) => opt.trim().length > 0); + + const displayPrefix = prefix ?? ""; + const questionText = `${displayPrefix}${question}`; + + if (hasSimpleOptions(options)) { + const buttons = options.map((opt, i) => ({ + type: "button" as const, + text: { type: "plain_text" as const, text: opt }, + action_id: `user_choice_${i}`, + value: opt, + })); + + const result = await client.chat.postMessage({ + channel: channelId, + thread_ts: threadId, + text: questionText, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: questionText }, + }, + { + type: "actions", + block_id: "user_choice", + elements: buttons, + }, + ], + token, + }); + return result.ts ?? undefined; + } + + const optionText = options.length > 0 ? `\nOptions: ${options.join(" / ")}` : ""; + const result = await client.chat.postMessage({ + channel: channelId, + thread_ts: threadId, + text: `${questionText}${optionText}`, + token, + }); + return result.ts ?? undefined; +} + function normalizeSlackUserId(userId: string): string { const trimmed = userId.trim(); if (trimmed.startsWith("<@") && trimmed.endsWith(">")) { @@ -181,32 +246,15 @@ async function handleSlackAction(payload: SlackActionRequest): Promise const options = Array.isArray(payload.options) ? payload.options.map(normalizeOptionLabel).filter((opt) => opt.trim().length > 0) : []; - if (options.length < 2 || options.length > 5) { - throw new Error("options must have 2-5 items"); + if (options.length < 2) { + throw new Error("options must have at least 2 items"); } - const buttons = options.map((opt, i) => ({ - type: "button" as const, - text: { type: "plain_text" as const, text: opt }, - action_id: `user_choice_${i}`, - value: opt, - })); - - await client.chat.postMessage({ - channel: channelId, - thread_ts: threadId, - text: question, - blocks: [ - { - type: "section", - text: { type: "mrkdwn", text: question }, - }, - { - type: "actions", - block_id: "user_choice", - elements: buttons, - }, - ], + await postSlackQuestion({ + channelId, + threadId, + question, + options, token, }); diff --git a/packages/ims/slack/client.ts b/packages/ims/slack/client.ts index d2c87dd..6151875 100644 --- a/packages/ims/slack/client.ts +++ b/packages/ims/slack/client.ts @@ -613,6 +613,30 @@ function createSlackAdapter(processorId?: string): IMAdapter { maxEditableMessageChars: 35_000, sendMessage: (channelId: string, threadId: string, text: string) => sendMessage(channelId, threadId, text, processorId), + sendQuestion: async ( + channelId: string, + threadId: string, + question: string, + options: string[] | undefined, + prefix?: string + ) => { + const token = getSlackBotTokenForProcessor(processorId) ?? getSlackBotToken(channelId, threadId); + if (!token) { + // No token -> fall through to plain-text sendMessage so the question + // still gets delivered through whatever channel/path the caller has. + const optionText = options && options.length > 0 ? `\nOptions: ${options.join(" / ")}` : ""; + return sendMessage(channelId, threadId, `${prefix ?? ""}${question}${optionText}`, processorId); + } + const { postSlackQuestion } = await import("./api"); + return postSlackQuestion({ + channelId, + threadId, + question, + options, + prefix, + token, + }); + }, updateMessage: (channelId: string, messageTs: string, text: string) => updateMessage(channelId, messageTs, text, processorId), cancelPendingUpdates: (channelId: string, messageTs: string) =>