Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions packages/agents/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:");
Expand Down
15 changes: 13 additions & 2 deletions packages/core/kernel/pending-question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
16 changes: 14 additions & 2 deletions packages/core/kernel/request-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions packages/core/runtime/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,21 @@ export function formatSingleQuestionPrompt(
export function buildQuestionAnswers(answers: string[]): Array<Array<string>> {
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;
}
74 changes: 74 additions & 0 deletions packages/core/test/pending-question.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
32 changes: 32 additions & 0 deletions packages/core/test/runtime-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
categorizeRuntimeError,
formatQuestionPrompt,
formatSingleQuestionPrompt,
hasSimpleOptions,
} from "../runtime/helpers";

describe("runtime helpers", () => {
Expand Down Expand Up @@ -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);
});
});
});
14 changes: 14 additions & 0 deletions packages/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ export type AgentStatusMessageParams = {
export interface IMAdapter {
maxEditableMessageChars?: number;
sendMessage(channelId: string, threadId: string, text: string): Promise<string | undefined>;
/**
* 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<string | undefined>;
updateMessage(
channelId: string,
messageTs: string,
Expand Down
96 changes: 72 additions & 24 deletions packages/ims/slack/api.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<string | undefined> {
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(">")) {
Expand Down Expand Up @@ -181,32 +246,15 @@ async function handleSlackAction(payload: SlackActionRequest): Promise<unknown>
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,
});

Expand Down
24 changes: 24 additions & 0 deletions packages/ims/slack/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading