enhance: bring Slack remote questions to parity (#628)

* enhance: bring Slack remote questions to parity

* chore(M004): record integration branch

* fix: restore remote questions adapter import
This commit is contained in:
Colin Johnson 2026-03-16 11:01:41 -04:00 committed by GitHub
parent 2fd4a1da60
commit 5fec6ea81e
10 changed files with 331 additions and 38 deletions

View file

@ -1,3 +1,3 @@
{
"integrationBranch": "main"
"integrationBranch": "Solvely/slack-remote-parity"
}

View file

@ -36,14 +36,14 @@ The setup wizard:
The setup wizard:
1. Prompts for your Slack bot token (`xoxb-...`)
2. Validates the token
3. Prompts for a channel ID
3. Lists channels the bot can access (with manual ID fallback)
4. Sends a test message to confirm permissions
5. Saves the configuration
**Bot requirements:**
- A Slack app with a bot token (from [Slack API](https://api.slack.com/apps))
- Bot must be invited to the target channel
- Required scopes: `chat:write`, `reactions:read`, `channels:history`
- Typical scopes for public/private channels: `chat:write`, `reactions:read`, `reactions:write`, `channels:read`, `groups:read`, `channels:history`, `groups:history`
## Configuration
@ -66,12 +66,12 @@ remote_questions:
- **Reacting** with a number emoji (1⃣, 2⃣, etc.) for single-question prompts
- **Replying** to the message with a number (`1`), comma-separated numbers (`1,3`), or free text
5. GSD picks up the response and continues execution
6. On Discord, a ✅ reaction is added to the prompt message to confirm receipt
6. A ✅ reaction is added to the prompt message to confirm receipt
### Response Formats
**Single question:**
- React with a number emoji (Discord only, single-question prompts)
- React with a number emoji (single-question prompts)
- Reply with a number: `2`
- Reply with free text (captured as a user note)
@ -98,13 +98,13 @@ If no response is received within `timeout_minutes`, the prompt times out and GS
| Feature | Discord | Slack |
|---------|---------|-------|
| Rich message format | Embeds with fields | Block Kit |
| Reaction-based answers | ✅ (single-question) | |
| Reaction-based answers | ✅ (single-question) | ✅ (single-question) |
| Thread-based replies | Message replies | Thread replies |
| Message URL in logs | ✅ | ✅ |
| Answer acknowledgement | ✅ reaction on receipt | Thread context |
| Answer acknowledgement | ✅ reaction on receipt | ✅ reaction on receipt |
| Multi-question support | Text replies (semicolons/newlines) | Text replies (semicolons/newlines) |
| Context source in prompt | ✅ (footer) | |
| Server/channel picker | ✅ (interactive) | Manual channel ID |
| Context source in prompt | ✅ (footer) | ✅ (context block) |
| Server/channel picker | ✅ (interactive) | ✅ (interactive + manual fallback) |
| Token validation | ✅ | ✅ |
| Test message on setup | ✅ | ✅ |

View file

@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parseSlackReply, parseDiscordResponse, formatForDiscord } from "../../remote-questions/format.ts";
import { parseSlackReply, parseDiscordResponse, formatForDiscord, formatForSlack, parseSlackReactionResponse } from "../../remote-questions/format.ts";
import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts";
import { sanitizeError } from "../../remote-questions/manager.ts";
@ -94,6 +94,21 @@ test("parseDiscordResponse rejects multi-question reaction parsing", () => {
assert.match(String(result.answers.second.user_note), /single-question prompts/i);
});
test("parseSlackReactionResponse handles single-question reactions", () => {
const result = parseSlackReactionResponse(["two"], [{
id: "choice",
header: "Choice",
question: "Pick one",
allowMultiple: false,
options: [
{ label: "Alpha", description: "A" },
{ label: "Beta", description: "B" },
],
}]);
assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } });
});
test("parseSlackReply truncates user_note longer than 500 chars", () => {
const longText = "x".repeat(600);
const result = parseSlackReply(longText, [{
@ -189,6 +204,65 @@ test("formatForDiscord includes context source in footer when present", () => {
assert.ok(embeds[0].footer?.text.includes("auto-mode-dispatch"), "footer should include context source");
});
test("formatForSlack includes context source when present", () => {
const blocks = formatForSlack({
id: "slack-1",
channel: "slack",
createdAt: Date.now(),
timeoutAt: Date.now() + 60000,
pollIntervalMs: 5000,
context: { source: "ask_user_questions" },
questions: [{
id: "q1",
header: "Confirm",
question: "Proceed?",
options: [
{ label: "Yes", description: "Continue" },
{ label: "No", description: "Stop" },
],
allowMultiple: false,
}],
});
const sourceBlock = blocks.find((block) => block.type === "context" && block.elements?.some((el) => el.text.includes("Source:")));
assert.ok(sourceBlock, "Slack blocks should include a context source block");
});
test("formatForSlack multi-question prompts explain semicolon and newline reply format", () => {
const blocks = formatForSlack({
id: "slack-2",
channel: "slack",
createdAt: Date.now(),
timeoutAt: Date.now() + 60000,
pollIntervalMs: 5000,
questions: [
{
id: "q1",
header: "First",
question: "Pick one",
options: [
{ label: "Alpha", description: "A" },
{ label: "Beta", description: "B" },
],
allowMultiple: false,
},
{
id: "q2",
header: "Second",
question: "Explain",
options: [
{ label: "Gamma", description: "G" },
{ label: "Delta", description: "D" },
],
allowMultiple: false,
},
],
});
const instructionBlock = blocks.find((block) => block.type === "context" && block.elements?.some((el) => el.text.includes("one line per question")));
assert.ok(instructionBlock, "Slack multi-question prompts should explain one-line or semicolon reply format");
});
test("formatForDiscord omits source from footer when context is absent", () => {
const prompt = {
id: "test-2",
@ -356,6 +430,27 @@ test("DiscordAdapter source-level: acknowledgeAnswer method exists", () => {
assert.ok(adapterSrc.includes("✅"), "should use checkmark emoji for acknowledgement");
});
test("SlackAdapter source-level: supports reaction polling and acknowledgement", () => {
const adapterSrc = readFileSync(
join(__dirname, "..", "..", "remote-questions", "slack-adapter.ts"),
"utf-8",
);
assert.ok(adapterSrc.includes("reactions.get"), "should poll Slack reactions");
assert.ok(adapterSrc.includes("reactions.add"), "should add Slack reactions");
assert.ok(adapterSrc.includes("async acknowledgeAnswer"), "should acknowledge Slack answers");
assert.ok(adapterSrc.includes("white_check_mark"), "should use a checkmark acknowledgement reaction");
});
test("Slack setup source-level: offers channel picker with manual fallback", () => {
const commandSrc = readFileSync(
join(__dirname, "..", "..", "remote-questions", "remote-command.ts"),
"utf-8",
);
assert.ok(commandSrc.includes("users.conversations"), "Slack setup should query Slack channels");
assert.ok(commandSrc.includes("Select a Slack channel"), "Slack setup should present a channel picker");
assert.ok(commandSrc.includes("Enter channel ID manually"), "Slack setup should preserve manual fallback");
});
test("DiscordAdapter source-level: resolves guild ID for message URLs", () => {
const adapterSrc = readFileSync(
join(__dirname, "..", "..", "remote-questions", "discord-adapter.ts"),

View file

@ -4,7 +4,7 @@ import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { fork } from "node:child_process";
import { spawn, type ChildProcess } from "node:child_process";
import { writeFileSync } from "node:fs";
import {
@ -25,6 +25,27 @@ function cleanup(base: string): void {
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
}
function waitForChildExit(child: ChildProcess, timeoutMs = 5000): Promise<number | null> {
return new Promise((resolve) => {
if (child.exitCode !== null) {
resolve(child.exitCode);
return;
}
const timeout = setTimeout(() => {
child.off("exit", onExit);
resolve(child.exitCode);
}, timeoutMs);
const onExit = (code: number | null) => {
clearTimeout(timeout);
resolve(code);
};
child.once("exit", onExit);
});
}
// ─── stopAutoRemote ──────────────────────────────────────────────────────
test("stopAutoRemote returns found:false when no lock file exists", () => {
@ -63,12 +84,16 @@ test("stopAutoRemote sends SIGTERM to a live process and returns found:true", as
const base = makeTmpBase();
// Spawn a child process that sleeps, acting as a fake auto-mode session
const child = fork(
"-e",
["process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(1), 30000);"],
const child = spawn(
process.execPath,
["-e", "process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(1), 30000);"],
{ stdio: "ignore", detached: false },
);
if (!child.pid) {
throw new Error("failed to spawn child process for stopAutoRemote test");
}
try {
// Wait for child to be ready
await new Promise((resolve) => setTimeout(resolve, 200));
@ -84,15 +109,13 @@ test("stopAutoRemote sends SIGTERM to a live process and returns found:true", as
};
writeFileSync(join(base, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2), "utf-8");
const exitPromise = waitForChildExit(child);
const result = stopAutoRemote(base);
assert.equal(result.found, true, "should find running auto-mode");
assert.equal(result.pid, child.pid, "should return the PID");
// Wait for child to exit (it should receive SIGTERM)
const exitCode = await new Promise<number | null>((resolve) => {
child.on("exit", (code) => resolve(code));
setTimeout(() => resolve(null), 5000);
});
const exitCode = await exitPromise;
// On Windows, SIGTERM is not interceptable — the process exits with code 1
// rather than running the handler. Accept either clean exit (0) or forced (1).
assert.ok(exitCode !== null, "child should have exited after SIGTERM");

View file

@ -3,12 +3,10 @@
*/
import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
import { formatForDiscord, parseDiscordResponse } from "./format.js";
import { formatForDiscord, parseDiscordResponse, DISCORD_NUMBER_EMOJIS } from "./format.js";
const DISCORD_API = "https://discord.com/api/v10";
const PER_REQUEST_TIMEOUT_MS = 15_000;
const NUMBER_EMOJIS = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣"];
export class DiscordAdapter implements ChannelAdapter {
readonly name = "discord" as const;
private botUserId: string | null = null;
@ -102,7 +100,7 @@ export class DiscordAdapter implements ChannelAdapter {
private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
const reactions: Array<{ emoji: string; count: number }> = [];
for (const emoji of NUMBER_EMOJIS) {
for (const emoji of DISCORD_NUMBER_EMOJIS) {
try {
const users = await this.discordApi("GET", `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`);
if (Array.isArray(users)) {

View file

@ -18,7 +18,8 @@ export interface DiscordEmbed {
footer?: { text: string };
}
const NUMBER_EMOJIS = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣"];
export const DISCORD_NUMBER_EMOJIS = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣"];
export const SLACK_NUMBER_REACTION_NAMES = ["one", "two", "three", "four", "five"];
const MAX_USER_NOTE_LENGTH = 500;
export function formatForSlack(prompt: RemotePrompt): SlackBlock[] {
@ -29,7 +30,18 @@ export function formatForSlack(prompt: RemotePrompt): SlackBlock[] {
},
];
if (prompt.questions.length > 1) {
blocks.push({
type: "context",
elements: [{
type: "mrkdwn",
text: "Reply once in thread using one line per question or semicolons (`1; 2; custom note`).",
}],
});
}
for (const q of prompt.questions) {
const supportsReactions = prompt.questions.length === 1;
blocks.push({
type: "section",
text: { type: "mrkdwn", text: `*${q.header}*\n${q.question}` },
@ -47,15 +59,33 @@ export function formatForSlack(prompt: RemotePrompt): SlackBlock[] {
type: "context",
elements: [{
type: "mrkdwn",
text: q.allowMultiple
? "Reply in thread with comma-separated numbers (`1,3`) or free text."
: "Reply in thread with a number (`1`) or free text.",
text: prompt.questions.length > 1
? (q.allowMultiple
? "For this question, use comma-separated numbers (`1,3`) or free text."
: "For this question, use one number (`1`) or free text.")
: (q.allowMultiple
? (supportsReactions
? "Reply in thread with comma-separated numbers (`1,3`) or react with matching number emoji."
: "Reply in thread with comma-separated numbers (`1,3`) or free text.")
: (supportsReactions
? "Reply in thread with a number (`1`) or react with the matching number emoji."
: "Reply in thread with a number (`1`) or free text.")),
}],
});
blocks.push({ type: "divider" });
}
if (prompt.context?.source) {
blocks.push({
type: "context",
elements: [{
type: "mrkdwn",
text: `Source: \`${prompt.context.source}\``,
}],
});
}
return blocks;
}
@ -64,8 +94,8 @@ export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[]
const embeds: DiscordEmbed[] = prompt.questions.map((q, questionIndex) => {
const supportsReactions = prompt.questions.length === 1;
const optionLines = q.options.map((opt, i) => {
const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`;
if (supportsReactions && NUMBER_EMOJIS[i]) reactionEmojis.push(NUMBER_EMOJIS[i]);
const emoji = DISCORD_NUMBER_EMOJIS[i] ?? `${i + 1}.`;
if (supportsReactions && DISCORD_NUMBER_EMOJIS[i]) reactionEmojis.push(DISCORD_NUMBER_EMOJIS[i]);
return `${emoji} **${opt.label}** — ${opt.description}`;
});
@ -130,8 +160,33 @@ export function parseDiscordResponse(
const q = questions[0];
const picked = reactions
.filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0)
.map((r) => q.options[NUMBER_EMOJIS.indexOf(r.emoji)]?.label)
.filter((r) => DISCORD_NUMBER_EMOJIS.includes(r.emoji) && r.count > 0)
.map((r) => q.options[DISCORD_NUMBER_EMOJIS.indexOf(r.emoji)]?.label)
.filter(Boolean) as string[];
answers[q.id] = picked.length > 0
? { answers: q.allowMultiple ? picked : [picked[0]] }
: { answers: [], user_note: "No clear response via reactions" };
return { answers };
}
export function parseSlackReactionResponse(
reactionNames: string[],
questions: RemoteQuestion[],
): RemoteAnswer {
const answers: RemoteAnswer["answers"] = {};
if (questions.length !== 1) {
for (const q of questions) {
answers[q.id] = { answers: [], user_note: "Slack reactions are only supported for single-question prompts" };
}
return { answers };
}
const q = questions[0];
const picked = reactionNames
.filter((name) => SLACK_NUMBER_REACTION_NAMES.includes(name))
.map((name) => q.options[SLACK_NUMBER_REACTION_NAMES.indexOf(name)]?.label)
.filter(Boolean) as string[];
answers[q.id] = picked.length > 0

View file

@ -5,8 +5,8 @@
import { randomUUID } from "node:crypto";
import type { ChannelAdapter, RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js";
import { resolveRemoteConfig, type ResolvedConfig } from "./config.js";
import { SlackAdapter } from "./slack-adapter.js";
import { DiscordAdapter } from "./discord-adapter.js";
import { SlackAdapter } from "./slack-adapter.js";
import { createPromptRecord, writePromptRecord, markPromptAnswered, markPromptDispatched, markPromptStatus, updatePromptRecord } from "./store.js";
interface ToolResult {
@ -77,10 +77,10 @@ export async function tryRemoteQuestions(
markPromptAnswered(prompt.id, answer);
// Acknowledge receipt with a ✅ on Discord (Slack threads are self-evident)
if (config.channel === "discord" && dispatch.ref) {
// Best-effort acknowledgement gives remote users a visible receipt signal.
if (dispatch.ref) {
try {
await (adapter as import("./discord-adapter.js").DiscordAdapter).acknowledgeAnswer(dispatch.ref);
await adapter.acknowledgeAnswer?.(dispatch.ref);
} catch { /* best-effort */ }
}

View file

@ -36,9 +36,28 @@ async function handleSetupSlack(ctx: ExtensionCommandContext): Promise<void> {
const auth = await fetchJson("https://slack.com/api/auth.test", { headers: { Authorization: `Bearer ${token}` } });
if (!auth?.ok) return void ctx.ui.notify("Token validation failed — check the token and app install.", "error");
const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)");
const channels = await listSlackChannels(token);
const MANUAL_OPTION = "Enter channel ID manually";
let channelId: string;
if (!channels || channels.length === 0) {
ctx.ui.notify("Could not list Slack channels — falling back to manual entry.", "warning");
channelId = await promptSlackChannelId(ctx) ?? "";
} else {
const channelOptions = [...channels.map((channel) => channel.label), MANUAL_OPTION];
const selectedChannel = await ctx.ui.select("Select a Slack channel", channelOptions);
if (!selectedChannel) return void ctx.ui.notify("Slack setup cancelled.", "info");
if (selectedChannel === MANUAL_OPTION) {
channelId = await promptSlackChannelId(ctx) ?? "";
} else {
const chosen = channels.find((channel) => channel.label === selectedChannel);
if (!chosen) return void ctx.ui.notify("Slack setup cancelled.", "info");
channelId = chosen.id;
}
}
if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info");
if (!isValidChannelId("slack", channelId)) return void ctx.ui.notify("Invalid Slack channel ID format — expected 9-12 uppercase alphanumeric characters.", "error");
const send = await fetchJson("https://slack.com/api/chat.postMessage", {
method: "POST",
@ -203,6 +222,52 @@ async function fetchJson(url: string, init?: RequestInit): Promise<any> {
}
}
async function listSlackChannels(token: string): Promise<Array<{ id: string; label: string }> | null> {
const headers = { Authorization: `Bearer ${token}` };
const channels: Array<{ id: string; label: string; name: string }> = [];
let cursor = "";
do {
const params = new URLSearchParams({
exclude_archived: "true",
limit: "200",
types: "public_channel,private_channel",
});
if (cursor) params.set("cursor", cursor);
const response = await fetchJson(`https://slack.com/api/users.conversations?${params.toString()}`, { headers });
if (!response?.ok || !Array.isArray(response.channels)) {
return channels.length > 0 ? channels.map(({ id, label }) => ({ id, label })) : null;
}
for (const channel of response.channels as Array<{ id?: string; name?: string; is_private?: boolean }>) {
if (!channel.id || !channel.name) continue;
channels.push({
id: channel.id,
name: channel.name,
label: channel.is_private ? `[private] ${channel.name}` : `#${channel.name}`,
});
}
cursor = typeof response.response_metadata?.next_cursor === "string"
? response.response_metadata.next_cursor
: "";
} while (cursor);
channels.sort((a, b) => a.name.localeCompare(b.name));
return channels.map(({ id, label }) => ({ id, label }));
}
async function promptSlackChannelId(ctx: ExtensionCommandContext): Promise<string | null> {
const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)");
if (!channelId) return null;
if (!isValidChannelId("slack", channelId)) {
ctx.ui.notify("Invalid Slack channel ID format — expected 9-12 uppercase alphanumeric characters.", "error");
return null;
}
return channelId;
}
function getAuthStorage(): AuthStorage {
const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json");
mkdirSync(dirname(authPath), { recursive: true });

View file

@ -3,10 +3,11 @@
*/
import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
import { formatForSlack, parseSlackReply } from "./format.js";
import { formatForSlack, parseSlackReply, parseSlackReactionResponse, SLACK_NUMBER_REACTION_NAMES } from "./format.js";
const SLACK_API = "https://slack.com/api";
const PER_REQUEST_TIMEOUT_MS = 15_000;
const SLACK_ACK_REACTION = "white_check_mark";
export class SlackAdapter implements ChannelAdapter {
readonly name = "slack" as const;
@ -36,6 +37,17 @@ export class SlackAdapter implements ChannelAdapter {
const ts = String(res.ts);
const channel = String(res.channel);
if (prompt.questions.length === 1) {
const reactionNames = SLACK_NUMBER_REACTION_NAMES.slice(0, prompt.questions[0].options.length);
for (const name of reactionNames) {
try {
await this.slackApi("reactions.add", { channel, timestamp: ts, name });
} catch {
// Best-effort only
}
}
}
return {
ref: {
id: prompt.id,
@ -51,6 +63,11 @@ export class SlackAdapter implements ChannelAdapter {
async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
if (!this.botUserId) await this.validate();
if (prompt.questions.length === 1) {
const reactionAnswer = await this.checkReactions(prompt, ref);
if (reactionAnswer) return reactionAnswer;
}
const res = await this.slackApi("conversations.replies", {
channel: ref.channelId,
ts: ref.threadTs!,
@ -66,9 +83,48 @@ export class SlackAdapter implements ChannelAdapter {
return parseSlackReply(String(userReplies[0].text), prompt.questions);
}
async acknowledgeAnswer(ref: RemotePromptRef): Promise<void> {
try {
await this.slackApi("reactions.add", {
channel: ref.channelId,
timestamp: ref.messageId,
name: SLACK_ACK_REACTION,
});
} catch {
// Best-effort only
}
}
private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
const res = await this.slackApi("reactions.get", {
channel: ref.channelId,
timestamp: ref.messageId,
full: "true",
});
if (!res.ok) return null;
const message = (res.message ?? {}) as {
reactions?: Array<{ name?: string; count?: number; users?: string[] }>;
};
const reactions = Array.isArray(message.reactions) ? message.reactions : [];
const picked = reactions
.filter((reaction) => reaction.name && SLACK_NUMBER_REACTION_NAMES.includes(reaction.name))
.filter((reaction) => {
const count = Number(reaction.count ?? 0);
const users = Array.isArray(reaction.users) ? reaction.users.map(String) : [];
const botIncluded = this.botUserId ? users.includes(this.botUserId) : false;
return count > (botIncluded ? 1 : 0);
})
.map((reaction) => String(reaction.name));
if (picked.length === 0) return null;
return parseSlackReactionResponse(picked, prompt.questions);
}
private async slackApi(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
const url = `${SLACK_API}/${method}`;
const isGet = method === "conversations.replies" || method === "auth.test";
const isGet = method === "conversations.replies" || method === "auth.test" || method === "reactions.get";
let response: Response;
if (isGet) {

View file

@ -72,4 +72,5 @@ export interface ChannelAdapter {
validate(): Promise<void>;
sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult>;
pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null>;
acknowledgeAnswer?(ref: RemotePromptRef): Promise<void>;
}