From 09f184cfc921aca1c5b0990b806d51ba7b076555 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sun, 13 Sep 2026 18:49:05 +0300 Subject: [PATCH] fix(slack): preserve workspace context for question continuations Signed-off-by: Tiberiu Socaci --- TEST-PLAN.md | 2 +- src/slack/app.js | 2 +- src/slack/questions.js | 11 +++++++---- test/question-interactions.test.js | 21 ++++++++++++++++++++- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/TEST-PLAN.md b/TEST-PLAN.md index e92af13..18898ce 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -5,7 +5,7 @@ Automated regression: `test/questions.test.js`, `test/question-views.test.js`, `test/question-interactions.test.js`, `test/question-continuation.test.js`, plus the gateway MCP inventory/approval, folder settings, busy-thread and recovery suites. The four question suites -pass 37 tests using scratch SQLite, fake Slack interactions and fixture engines. They cover +pass 38 tests using scratch SQLite, fake Slack interactions and fixture engines. They cover fresh-process draft retrieval, atomic submission/rollback, stale-card repair, serialized rendering, the Slack acknowledgement deadline, requester authorization, queue/restart recovery, and stop/clear. diff --git a/src/slack/app.js b/src/slack/app.js index debafee..d1b5f84 100644 --- a/src/slack/app.js +++ b/src/slack/app.js @@ -1633,7 +1633,7 @@ async function connectAndWire(app) { }); for (const a of APPROVAL_ACTIONS) app.action(a, handleApprovalClick); registerBusyThreadChoiceActions(app, processMessageEvent); - registerQuestionActions(app, processMessageEvent); + registerQuestionActions(app, processMessageEvent, { botUserId, teamId }); registerEngineSwitchChoiceActions(app, processMessageEvent); // Indexed ids (`cg_model_pick_2`) are the per-choice buttons; the bare id is the retired // static_select, still clickable in Slack history. One pattern covers both. diff --git a/src/slack/questions.js b/src/slack/questions.js index b882d6b..12939ee 100644 --- a/src/slack/questions.js +++ b/src/slack/questions.js @@ -204,8 +204,11 @@ export async function handleQuestionView({ ack, body, view = body?.view, client } } -export function registerQuestionActions(app, processMessage) { - app.action(/^cg_question_/, (payload) => handleQuestionAction(payload, { processMessage })); - app.view(QUESTION_FORM_CALLBACK, (payload) => handleQuestionView(payload, { processMessage })); - app.view(QUESTION_CUSTOM_CALLBACK, (payload) => handleQuestionView(payload, { processMessage })); +export function registerQuestionActions(app, processMessage, context = {}) { + // The live connection owns bot/workspace identity; the synthetic answer must retain it for + // mention hydration and Slack's recipient_team_id on streamed channel replies. + const continuation = (event, client, options) => processMessage(event, client, { ...context, ...options }); + app.action(/^cg_question_/, (payload) => handleQuestionAction(payload, { processMessage: continuation })); + app.view(QUESTION_FORM_CALLBACK, (payload) => handleQuestionView(payload, { processMessage: continuation })); + app.view(QUESTION_CUSTOM_CALLBACK, (payload) => handleQuestionView(payload, { processMessage: continuation })); } diff --git a/test/question-interactions.test.js b/test/question-interactions.test.js index 7bb255e..0f817f6 100644 --- a/test/question-interactions.test.js +++ b/test/question-interactions.test.js @@ -8,7 +8,7 @@ ensureTestEnv(); const { setUser, upsertChannelEntry, saveChannelMeta, getChannelMeta } = await import("../src/config/store.js"); const { getDb } = await import("../src/db/index.js"); const { getQuestion, saveQuestionAnswers } = await import("../src/gateway/questions.js"); -const { postQuestions, handleQuestionAction, handleQuestionView, refreshQuestionCard } = await import("../src/slack/questions.js"); +const { postQuestions, handleQuestionAction, handleQuestionView, refreshQuestionCard, registerQuestionActions } = await import("../src/slack/questions.js"); const { buildQuestionCard, buildQuestionModal, buildCustomAnswerModal } = await import("../src/slack/question-views.js"); const OWNER = "UQUESTION_OWNER"; @@ -79,6 +79,25 @@ test("posting is idempotent and visible card carries the persisted revision", as assert.equal(JSON.parse(elements(visible).find((el) => el.action_id === "cg_question_submit").value).revision, again.revision); }); +test("registered submit handlers preserve the live bot and workspace context", async () => { + const f = await fixture(); + await f.act("cg_question_choose:access:0"); + let handler; + let received; + registerQuestionActions({ action(_pattern, fn) { handler = fn; }, view() {} }, async (_event, _client, options) => { + received = options; + options.onQuestionSubmissionAccepted({ runId: randomUUID(), rec: f.context }); + }, { botUserId: "U_BOT_CONTEXT", teamId: "T_WORKSPACE_CONTEXT" }); + const record = f.current(); + const action = elements(buildQuestionCard(record)).find((el) => el.action_id === "cg_question_submit"); + await handler({ ack: async () => {}, action, client: f.client, body: { user: { id: OWNER }, channel: { id: CHANNEL }, message: { ts: record.messageTs } } }); + await settle(); + assert.equal(received.botUserId, "U_BOT_CONTEXT"); + assert.equal(received.teamId, "T_WORKSPACE_CONTEXT"); + assert.equal(received.bypassMention, true); + assert.equal(f.current().status, "submitted"); +}); + test("options and custom save are drafts; duplicate submit creates one durable continuation", async () => { const f = await fixture(); await f.act("cg_question_choose:access:0");