diff --git a/packages/agents/shared.ts b/packages/agents/shared.ts index 909b840..3315f42 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) =>