Files
PaperClipAI/server/src/services/chat-interaction-publications.ts
T
DottaandPaperclip 7ae81c9fa7 fix(slack): support rich questions and complete review delivery (#17255)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Questions and review decisions must work in the conversation where a
task started.
> - Slack omitted question context and did not publish several review
kinds.
> - Some answers saved in Paperclip also lost their authorized
continuation or their final Slack reply.
> - A settled card and a successful run did not prove that the user
received the result.
> - This pull request preserves complete questions and follows the
existing answer and delivery receipts through continuation.
> - Users can read the request, submit an authorized decision, and
receive the resumed task's result in the same conversation.

## Linked Issues or Issue Description

Refs #17045 and #17218.

**What happened?**

Slack question cards could omit instructions and editable starting text.
Checkbox reviews, item verdicts, task suggestions, and connection
requests had no publication path. Complex question answers submitted in
Paperclip could fail continuation authorization. Several review
continuations completed without delivering their result to Slack.

**Expected behavior**

Show the actual question and context. Use native Slack controls when
they preserve the form contract. Otherwise provide an explicit Paperclip
review link. Persist one authorized response, resume through the
existing delivery path, settle the card, and deliver the useful result
to its originating conversation.

**Steps to reproduce**

1. Start a task in Slack and ask a text or choice question with
instructions and help text.
2. Submit native text and choice responses, then submit a complex form
through Paperclip.
3. Accept a checklist, submit item decisions, select suggested tasks, or
decline a connection request.
4. Inspect the saved answer, continuation run, original Slack card, and
final Slack reply independently.

## What Changed

- Preserve full prompts, context, descriptions, recommendations, help,
and editable starting text. Use direct buttons for short choices, inline
searchable selects for longer lists, and native checkboxes with explicit
submission for multiple choices.
- Add native custom-answer and validated-text modals, ordinary
confirmation buttons, checklist decisions, per-item verdicts with
reasons, suggested-task creation/dismissal, and connection decline. Keep
governed approvals, permission grants, and forms that cannot be
represented faithfully in the full Paperclip review surface.
- Keep Slack-specific canonical-validation feedback in its owning
platform module. Shared callbacks delegate without interpreting provider
presentation.
- Reuse the existing canonical interaction transactions, callback
receipts, publication queue, and continuation delivery. Share ordinary
confirmation handling across Telegram and Slack. Recheck current
authorization and commit response receipts atomically.
- Settle cards with the saved answer and remove stale controls. Ignore
passive Slack link callbacks so opening a task does not display a false
answer error.
- Validate exact multi-select receipt mappings during native
continuation. Recover automation-origin connection replies from their
published prompt after validating the durable connection receipt.
- Keep oversized multi-select handoffs free of unused native tokens, and
retain the complete Paperclip form for 101–128 choices plus Other. Make
the original-conversation audience explicit before submitting a Slack
modal.
- Add rollback, provider serialization, callback, continuation, and
negative authorization regressions. Update the connector playbook and
feature/access maps. No React or application UI changes are included.
- Add a durable Q01–Q11 qualification index and provider coverage
matrix. Require distinct functional, good-experience, and
best-available-control assessments; real unanswered/answering/settled
evidence; saved results, continuation and navigation proof; and retained
failures. Mark other providers unaudited rather than assuming Slack
parity.

## Verification

Candidate: `e0017d446b1016a38d65382366cd1408cce33039`. The
eleven-core-format live Slack gallery is pinned to
`c3d458b2a06b660448940fd8cf33c5906f0058d5`; subsequent boundary fixes
and the sharing notice have automated verification, not new live
screenshots.

- Real Slack UI testing covered all 11 formats: text, closed choice,
long single select, multiple choices, choices with a custom answer,
pattern-validated text, confirmation, checklist, item verdicts,
suggested tasks, and connection requests. Each has
unanswered/answering/answered screenshots as applicable, an inspected
saved result, one continuation, and an individual experience review in
the local HTML gallery.
- The dedicated private QA app/channel used signed callbacks to the
local candidate. A separately approved password-protected, read-only
preview proved connection-review, suggested-task review, and
created-task navigation. Anonymous access, writes, and credential routes
were denied. No OAuth grant was attempted.
- Real testing exposed and repaired a multi-select continuation failure
and a misleading error on link-button clicks. Failed attempts remain in
the evidence. The final navigation replay opened the exact pending
connection request and declined it in Slack without granting access.
Suggested-task creation produced the selected child with the correct
parent and description.
- Final review-fix verification: **32 form tests**, **28
provider/form-rendering tests**, **30 publication tests**, and **171
native continuation/authorization tests** passed. Earlier **3
atomic-resolution rollback**, **9 targeted
callback/publication/Telegram**, and **28 review-continuation** cases
passed. The final Slack/Teams modal subset also passed. The
automation-origin connection cases, 101/128 custom-choice cases, and
oversized multi-select continuation reproduced before their fixes and
passed afterward; failing logs remain retained. These counts describe
relevant runs, not a reliability percentage.
- CI on `b5885b85fd` exposed a test fixture that assumed every Slack
modal block was an input; the new sharing notice correctly renders a
non-input block. The fixture now selects input blocks safely. The exact
signed-modal correction/one-continuation test passed locally, together
with 14 Slack form/modal tests. The failing CI log and its specific
cause remain recorded.
- `pnpm -r typecheck` and `pnpm build` passed. Server typechecking was
repeated after the final source changes. Map and module-boundary checks
and `git diff --check` passed.
- The full `pnpm test:run` was attempted and stopped after repeated
embedded PostgreSQL semaphore exhaustion on this host. Its 1,395-test
chat suite found two stale native-control assertions; both were
corrected and re-tested in a focused run. Focused database suites used
separate disposable databases on existing local PostgreSQL. The full
suite is not claimed green; new-head CI must complete before merge.
- Final-head CI passed on `e0017d446b1016a38d65382366cd1408cce33039`:
all test/build jobs and required `ci / verify` and `ci / e2e` gates are
green
([run](https://github.com/paperclipai/paperclip/actions/runs/40115430194)).
Greptile and Ponytail both reviewed this exact head at 5/5, the security
scan passed, and all review threads are resolved.
- All three QA app callbacks were restored to staging. Both temporary
tunnels and local proxies were closed. The local endpoint was archived
and its local credential copies removed. The candidate app was stopped.

Limits: provider execution used a deterministic Claude protocol fixture,
not an LLM. This is local real-provider UX proof, not deployed staging
or broader Codie/Claude qualification. The public preview intentionally
blocked ancillary artifact routes, so its destination pages could show a
partial-history warning. The complete permission-grant flow and every
oversized/mixed-form branch were not driven manually. Earlier fixture,
test, and provider failures remain recorded.

## Risks

- New reply routes require durable origin proof. Incorrect receipt
matching could lose a reply or route it incorrectly. Tests cover
missing, forged, mismatched, and revoked authority.
- Governed forms keep their Paperclip resolution checks. This change
does not grant new approval authority to Slack.
- Existing completed review results can become deliverable through the
repaired publication path without another provider run.
- No schema, lockfile, workflow, public task-status model, or
runtime-instruction change is included. This branch is not deployed.

## Model Used

OpenAI Codex, GPT-6 family, with code execution and tool use. The exact
backend model ID and context-window size are not exposed in this
session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green on the new head
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
on the new head
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-24 10:29:15 -07:00

857 lines
36 KiB
TypeScript

import { chatInteractionForm } from "./chat-interaction-forms.js";
import { slackInteractionRequiresBoard } from "./chat-channels/slack/cards.js";
import { answeredChatQuestionBody, chatInteractionPresentation } from "./chat-interaction-presentation.js";
import { notifyChatPublicationWork } from "./chat-work-notifications.js";
import { nativePhotonInteraction } from "./photon/interactions.js";
import { randomBytes } from "node:crypto";
import { and, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
chatActions,
chatConversations,
chatEndpoints,
chatPublications,
heartbeatRuns,
issues,
} from "@paperclipai/db";
import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
IssueThreadInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
SuggestTasksInteraction,
SafeExternalChatCardAction,
} from "@paperclipai/shared";
import { projectSafeChatPublication } from "./chat-publication-projection.js";
import { publicChatTaskUrl } from "./chat-task-url.js";
import {
chatQuestionFormActionRecords,
createChatQuestionFormDraft,
} from "./chat-question-forms.js";
const MAX_NATIVE_QUESTION_OPTIONS = 12;
const QUESTION_ACTION_PREFIX = "pcq:";
const QUESTION_ACTION_TOKEN_BYTES = 16;
export const CHAT_QUESTION_ACTION_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
export const TELEGRAM_CALLBACK_DATA_LIMIT_BYTES = 64;
type ChatPublicationDb = Pick<Db, "select" | "insert" | "update" | "transaction">;
function terminalNativeInteractionCopy(
interaction: IssueThreadInteraction,
): { body: string; text: string } | null {
if (interaction.kind === "request_confirmation") {
if (interaction.status === "accepted")
return { body: "Accepted", text: "Accepted." };
if (interaction.status === "rejected")
return { body: "Rejected", text: "Rejected." };
if (interaction.status === "cancelled") {
const outcome = interaction.result?.outcome;
const body =
outcome === "skipped"
? "Skipped in Paperclip"
: outcome === "withdrawn"
? "Withdrawn in Paperclip"
: outcome === "addressee_deleted"
? "Cancelled: addressed agent was removed"
: "Cancelled in Paperclip";
return { body, text: `${body}.` };
}
if (interaction.status === "expired") {
const body =
interaction.result?.outcome === "superseded_by_comment"
? "Expired: superseded by a newer reply"
: interaction.result?.outcome === "superseded_by_newer_request"
? "Expired: replaced by a newer request"
: interaction.result?.outcome === "stale_target"
? "Expired: target is no longer current"
: interaction.result?.outcome === "issue_closed"
? "Expired: task is closed"
: "Expired in Paperclip";
return { body, text: `${body}.` };
}
return null;
}
if (interaction.kind === "request_checkbox_confirmation" && interaction.status === "accepted") {
const labels = interaction.payload.options.filter(option => interaction.result?.selectedOptionIds?.includes(option.id)).map(option => option.label);
const body = labels.length ? `Saved checks: ${labels.join("; ")}` : "Saved checks: none selected.";
return { body, text: body };
}
if (interaction.kind === "request_item_verdicts" && interaction.status === "answered") {
const body = interaction.payload.items.map(item => {
const result = interaction.result?.items.find(result => result.id === item.id);
return `${item.label}: ${result?.verdict ?? "pending"}${result?.reason ? ` — ${result.reason}` : ""}`;
}).join("\n\n");
return { body, text: body };
}
if (interaction.kind === "suggest_tasks" && (interaction.status === "accepted" || interaction.status === "rejected")) {
const body = interaction.status === "rejected" ? "Dismissed — no tasks created." :
(interaction.result?.createdTasks ?? []).map(task => {
const url = publicChatTaskUrl(task.issueId);
return `Created: ${task.identifier ?? "Task"} — ${task.title}${url ? `\n${url}` : ""}`;
}).join("\n\n");
return { body, text: body };
}
if (interaction.kind === "connection_intent" && (interaction.status === "accepted" || interaction.status === "rejected")) {
const body = interaction.status === "accepted" ? `Connected: ${interaction.payload.serviceName}` : `Declined: ${interaction.payload.serviceName}. No access was granted.`;
return { body, text: body };
}
if (interaction.kind !== "ask_user_questions") {
const body = ({ accepted: "Accepted", rejected: "Rejected", answered: "Answered", cancelled: "Cancelled in Paperclip", expired: "Expired in Paperclip" } as Record<string, string>)[interaction.status];
return body ? { body, text: `${body}.` } : null;
}
if (interaction.status === "answered") {
const question = nativeChatQuestion(interaction);
const answer = question
? interaction.result?.answers.find(
(candidate) => candidate.questionId === question.id,
)
: null;
const option =
answer?.optionIds.length === 1 && !answer.otherText
? question?.options.find(
(candidate) => candidate.id === answer.optionIds[0],
)
: null;
const body = option ? `Answered: ${option.label}.` : answeredChatQuestionBody(interaction);
return { body, text: body };
}
if (interaction.status === "cancelled") {
const outcome = interaction.result?.outcome;
const body =
outcome === "skipped"
? "Skipped in Paperclip."
: outcome === "withdrawn"
? "Withdrawn in Paperclip."
: "Cancelled in Paperclip.";
return { body, text: body };
}
if (interaction.status === "expired") {
const body =
interaction.result?.expirationReason === "superseded_by_comment"
? "Expired: superseded by a newer reply"
: interaction.result?.expirationReason ===
"superseded_by_newer_interaction"
? "Expired: replaced by a newer request"
: interaction.result?.outcome === "issue_closed"
? "Expired: task is closed"
: "Expired in Paperclip";
return { body, text: `${body}.` };
}
return null;
}
export const publicChatInteractionTaskUrl = publicChatTaskUrl;
/**
* Provider callbacks carry this compact, cryptographically random action id.
* Its durable chat_actions row is the only mapping back to an interaction,
* question, and option. Keeping the token opaque avoids exposing canonical ids
* and fits Telegram's strict 64-byte callback_data envelope.
*/
export function createChatQuestionOptionActionToken(): string {
return `${QUESTION_ACTION_PREFIX}${randomBytes(QUESTION_ACTION_TOKEN_BYTES).toString("base64url")}`;
}
export function createChatConfirmationActionToken(): string {
return createChatQuestionOptionActionToken();
}
/** Mirrors the pinned Chat SDK Telegram adapter's exact wire envelope. */
export function telegramChatSdkCallbackData(
actionId: string,
value?: string,
): string {
return `chat:${JSON.stringify({
a: actionId,
...(typeof value === "string" ? { v: value } : {}),
})}`;
}
export function telegramCallbackDataByteLength(
actionId: string,
value?: string,
): number {
return Buffer.byteLength(
telegramChatSdkCallbackData(actionId, value),
"utf8",
);
}
export function nativeChatQuestion(
interaction: AskUserQuestionsInteraction,
maxOptions = MAX_NATIVE_QUESTION_OPTIONS,
): AskUserQuestionsQuestion | null {
if (interaction.payload.questions.length !== 1) return null;
const question = interaction.payload.questions[0];
if (
question.selectionMode !== "single" ||
question.allowOther === true ||
question.options.length > maxOptions ||
question.options.some((option) => option.freeText === true)
) {
return null;
}
return question;
}
export function nativeChatCustomQuestion(interaction: AskUserQuestionsInteraction): AskUserQuestionsQuestion | null {
const question = interaction.payload.questions[0];
if (interaction.payload.questions.length !== 1 || !question || question.selectionMode !== "single" || !question.allowOther ||
!interaction.payload.questionSet?.questions[0]?.customAnswer) return null;
const options = question.options.filter(option => !option.freeText);
return options.length > 0 && options.length <= 100 ? { ...question, options } : null;
}
export function nativeChatMultiQuestion(interaction: AskUserQuestionsInteraction): AskUserQuestionsQuestion | null {
const question = interaction.payload.questions[0];
if (interaction.payload.questions.length !== 1 || !question || question.selectionMode !== "multi" || question.allowOther || question.options.length === 0 || question.options.length > 100 || question.options.some(option => option.freeText)) return null;
return question;
}
export function nativeChatSuggestions(interaction: IssueThreadInteraction): SuggestTasksInteraction | null {
return interaction.kind === "suggest_tasks" && interaction.payload.tasks.filter(task => !task.hiddenInPreview).length > 0 && interaction.payload.tasks.length <= 100 ? interaction : null;
}
export function nativeChatCheckbox(interaction: IssueThreadInteraction): RequestCheckboxConfirmationInteraction | null {
return interaction.kind === "request_checkbox_confirmation" && !interaction.payload.rejectRequiresReason &&
interaction.payload.target?.type !== "issue_document" && interaction.payload.options.length <= 100 ? interaction : null;
}
/**
* Slack and Telegram can render ordinary binary confirmations as inline buttons.
* Confirmations that collect a rejection reason or authorize a credential,
* connection, or tool side effect stay in Paperclip, where the complete
* governed review UI and permission checks are available.
*/
export function nativeChatConfirmation(
interaction: IssueThreadInteraction,
): RequestConfirmationInteraction | null {
if (interaction.kind !== "request_confirmation") return null;
if (
interaction.payload.rejectRequiresReason === true ||
interaction.payload.toolAction !== undefined ||
interaction.payload.secretProposal !== undefined ||
interaction.payload.connectionAuthorization !== undefined ||
interaction.payload.target?.type === "issue_document"
) {
return null;
}
return interaction;
}
/**
* Enqueues one immutable outbox item per live external task binding. A lone
* closed single-select question receives compact executable buttons. Slack and
* Teams can also receive an opaque modal opener for safe text and closed
* single-select forms. Unsupported question shapes stay link-only.
*/
export async function enqueueIssueInteractionChatPublications(
db: ChatPublicationDb,
interaction: IssueThreadInteraction,
) {
if (interaction.status !== "pending") return [];
// An endpoint is one immutable provider bot identity. Never externalize a
// user/system-authored interaction, or let one agent speak through another
// agent's endpoint. If the interaction names a source run, verify that run's
// company and agent instead of trusting the denormalized creator alone.
if (!interaction.createdByAgentId) return [];
if (interaction.sourceRunId) {
const sourceRun = await db
.select({ agentId: heartbeatRuns.agentId })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, interaction.sourceRunId),
eq(heartbeatRuns.companyId, interaction.companyId),
eq(heartbeatRuns.agentId, interaction.createdByAgentId),
),
)
.then((rows) => rows[0] ?? null);
if (!sourceRun) return [];
}
const bindings = await db
.select({
conversation: chatConversations,
endpoint: chatEndpoints,
})
.from(chatConversations)
.innerJoin(
chatEndpoints,
and(
eq(chatEndpoints.companyId, chatConversations.companyId),
eq(chatEndpoints.id, chatConversations.endpointId),
eq(chatEndpoints.publicationMode, "automatic"),
),
)
.where(
and(
eq(chatConversations.companyId, interaction.companyId),
eq(chatConversations.issueId, interaction.issueId),
inArray(chatConversations.state, ["active", "waiting"]),
inArray(chatEndpoints.status, ["active", "verifying"]),
),
);
if (bindings.length === 0) return [];
const taskUrl = publicChatInteractionTaskUrl(interaction.issueId);
const inserted: Array<typeof chatPublications.$inferSelect> = [];
for (const { conversation, endpoint } of bindings) {
if (endpoint.assignedAgentId !== interaction.createdByAgentId) continue;
if (endpoint.provider !== "slack" && interaction.kind !== "ask_user_questions" && interaction.kind !== "request_confirmation") continue;
let connectionDecline = endpoint.provider === "slack" && endpoint.capabilities.actions && interaction.kind === "connection_intent" ? interaction : null;
let suggestions = endpoint.provider === "slack" && endpoint.capabilities.actions ? nativeChatSuggestions(interaction) : null;
const suggestedOptions = suggestions?.payload.tasks.filter(task => !task.hiddenInPreview).map(task => ({ actionId: createChatQuestionOptionActionToken(), option: { id: task.clientKey, label: task.title, description: task.description } })) ?? [];
let checklist = endpoint.provider === "slack" && endpoint.capabilities.actions ? nativeChatCheckbox(interaction) : null;
const checklistOptions = checklist?.payload.options.map(option => ({ actionId: createChatQuestionOptionActionToken(), option })) ?? [];
const customQuestion = endpoint.provider === "slack" && endpoint.capabilities.modals === true && interaction.kind === "ask_user_questions" ? nativeChatCustomQuestion(interaction) : null;
const multipleQuestion = endpoint.provider === "slack" && interaction.kind === "ask_user_questions" ? nativeChatMultiQuestion(interaction) : null;
const question = interaction.kind === "ask_user_questions"
? multipleQuestion ?? customQuestion ?? nativeChatQuestion(interaction, endpoint.provider === "slack" ? 100 : MAX_NATIVE_QUESTION_OPTIONS) : null;
const inlineSelection = endpoint.provider === "slack" && question && (multipleQuestion !== null || question.options.length > 5);
const multipleActionId = multipleQuestion ? createChatQuestionOptionActionToken() : null;
const presentation = chatInteractionPresentation(interaction, { compactChoices: Boolean(inlineSelection || checklist || suggestedOptions.length > 1), nativeCustomAnswer: Boolean(customQuestion) });
const requiresBoard = endpoint.provider === "slack" && slackInteractionRequiresBoard(presentation.body);
if (requiresBoard) {
connectionDecline = null;
suggestions = null;
checklist = null;
}
const formInteraction = chatInteractionForm(interaction, endpoint.provider);
const formDraft =
!requiresBoard && (!inlineSelection || customQuestion) && formInteraction &&
(endpoint.provider === "slack" ||
endpoint.provider === "discord" ||
endpoint.provider === "microsoft-teams") &&
endpoint.capabilities.actions === true &&
endpoint.capabilities.modals === true
? createChatQuestionFormDraft(
formInteraction,
(endpoint.provider === "discord" || endpoint.provider === "slack")
? { nativeProvider: endpoint.provider }
: {},
)
: null;
const supportsCallbacks =
!requiresBoard && endpoint.provider !== "imessage-photon" &&
(formDraft === null || customQuestion !== null) &&
question !== null &&
endpoint.capabilities.actions === true;
const questionActionTokens = supportsCallbacks
? question.options.map((option) => ({
actionId: createChatQuestionOptionActionToken(),
option,
}))
: [];
const confirmation =
!requiresBoard && (endpoint.provider === "telegram" || endpoint.provider === "slack") && endpoint.capabilities.actions === true
? nativeChatConfirmation(interaction) ?? checklist
: null;
const confirmationActionTokens = confirmation || suggestions || connectionDecline
? (connectionDecline ? ["reject"] as const : ["accept", "reject"] as const).map((decision) => ({
actionId: createChatConfirmationActionToken(),
decision,
}))
: [];
if (
endpoint.provider === "telegram" &&
[...questionActionTokens, ...confirmationActionTokens].some(
({ actionId }) =>
telegramCallbackDataByteLength(actionId) >
TELEGRAM_CALLBACK_DATA_LIMIT_BYTES,
)
) {
throw new Error("Generated Telegram question action exceeds 64 bytes");
}
const actions: SafeExternalChatCardAction[] = formDraft
? [
...(customQuestion && !inlineSelection ? questionActionTokens.map(({ actionId, option }) => ({ type: "callback" as const, actionId, label: option.label })) : []),
{
type: "callback" as const,
actionId: formDraft.openActionId,
label: customQuestion ? "Other…" : interaction.kind === "request_item_verdicts" ? "Review decisions" : "Respond",
style: customQuestion ? "default" as const : "primary" as const,
},
]
: supportsCallbacks
? (inlineSelection ? [] : questionActionTokens).map(({ actionId, option }) => ({
type: "callback" as const,
actionId,
label: option.label,
}))
: confirmation || suggestions || connectionDecline
? confirmationActionTokens.filter(item => !(checklist || suggestedOptions.length > 1) || item.decision !== "accept").map(({ actionId, decision }) => ({
type: "callback" as const,
actionId,
label:
decision === "accept"
? (suggestions ? "Create task" : confirmation!.payload.acceptLabel ?? "Accept")
: (connectionDecline ? "Not now" : suggestions ? "Dismiss" : confirmation!.payload.rejectLabel ?? "Reject"),
style:
decision === "accept"
? ("primary" as const)
: (connectionDecline || suggestions ? "default" as const : "danger" as const),
}))
: taskUrl
? [
{
type: "link" as const,
label: "Open in Paperclip",
url: taskUrl,
},
]
: [];
if (connectionDecline && taskUrl) actions.unshift({ type: "link", label: "Review connection", url: taskUrl });
if (suggestions && taskUrl) actions.push({ type: "link", label: "Revise in Paperclip", url: taskUrl });
const text = [presentation.title, presentation.body, taskUrl ? `Open the task in Paperclip to respond: ${taskUrl}` : "Open the task in Paperclip to respond."].join("\n\n");
const payload = projectSafeChatPublication({
classification: "external",
source: "issue_interaction",
text,
progressState: "waiting_for_input",
interaction: {
id: interaction.id,
card: {
kind:
interaction.kind === "ask_user_questions"
? "question"
: interaction.kind === "request_confirmation"
? "confirmation"
: "status",
title: interaction.kind === "ask_user_questions" && question ? question.prompt : presentation.title,
body: presentation.body,
actions,
...(suggestions && suggestedOptions.length > 1 ? { selection: {
actionId: confirmationActionTokens.find(item => item.decision === "accept")!.actionId,
label: "Select tasks to create", multiple: true, submitLabel: "Create selected",
options: suggestedOptions.map(({ actionId, option }) => ({ actionId, label: option.label, ...(option.description ? { description: option.description } : {}) })),
} } : {}),
...(checklist ? { selection: {
actionId: confirmationActionTokens.find(item => item.decision === "accept")!.actionId,
label: "Select checks", multiple: true, submitLabel: checklist.payload.acceptLabel ?? "Save checks",
options: checklistOptions.map(({ actionId, option }) => ({ actionId, label: option.label,
...(option.description ? { description: option.description } : {}),
selected: checklist.payload.defaultSelectedOptionIds?.includes(option.id) ?? false })),
} } : {}),
...(inlineSelection && supportsCallbacks ? { selection: {
actionId: multipleActionId ?? `pcs:${interaction.id}`,
label: multipleQuestion ? "Choose one or more" : "Choose an answer",
...(multipleQuestion ? { multiple: true, submitLabel: "Submit answer" } : {}),
options: questionActionTokens.map(({ actionId, option }) => ({ actionId,
label: option.label + (interaction.kind === "ask_user_questions" && interaction.payload.questionSet?.questions.find(item => item.id === question.id)?.options?.find(item => item.id === option.id)?.recommended ? " (recommended)" : ""),
...(option.description ? { description: option.description } : {}) })),
} } : {}),
},
},
});
const rows = await db.transaction(async (tx) => {
await notifyChatPublicationWork(tx);
const rows = await tx
.insert(chatPublications)
.values({
companyId: interaction.companyId,
endpointId: endpoint.id,
conversationId: conversation.id,
issueId: interaction.issueId,
idempotencyKey: `interaction:${interaction.id}:${endpoint.id}`,
payload,
state: "pending",
})
.onConflictDoNothing()
.returning();
const publication = rows[0];
if (publication && endpoint.provider === "imessage-photon" && nativePhotonInteraction(interaction)) {
const reference = randomBytes(9).toString("base64url");
await tx.insert(chatActions).values({ companyId: interaction.companyId, endpointId: endpoint.id, conversationId: conversation.id,
kind: "photon_interaction", providerActionId: `photon:${reference}`,
payload: { version: 1, reference, interactionId: interaction.id, publicationId: publication.id, sessionGeneration: conversation.sessionGeneration,
expiresAt: new Date(publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS).toISOString() }, status: "issued" });
} else if (publication && formDraft) {
await tx.insert(chatActions).values(
chatQuestionFormActionRecords(formDraft, {
companyId: interaction.companyId,
endpointId: endpoint.id,
conversationId: conversation.id,
publicationId: publication.id,
}),
);
}
if (publication && supportsCallbacks && multipleQuestion && multipleActionId) {
await tx.insert(chatActions).values({
companyId: interaction.companyId, endpointId: endpoint.id, conversationId: conversation.id,
kind: "question_answer", providerActionId: multipleActionId, status: "issued",
payload: { version: 1, publicationId: publication.id, interactionId: interaction.id, questionId: multipleQuestion.id,
selectionOptions: questionActionTokens.map(({ actionId, option }) => ({ value: actionId, optionId: option.id })),
expiresAt: new Date(publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS).toISOString() },
});
} else if (publication && question && questionActionTokens.length > 0) {
const expiresAt = new Date(
publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS,
).toISOString();
await tx.insert(chatActions).values(
questionActionTokens.map(({ actionId, option }) => ({
companyId: interaction.companyId,
endpointId: endpoint.id,
conversationId: conversation.id,
kind: "question_answer",
providerActionId: actionId,
payload: {
version: 1,
publicationId: publication.id,
interactionId: interaction.id,
questionId: question.id,
optionId: option.id,
expiresAt,
},
status: "issued",
})),
);
} else if (
publication &&
(confirmation || suggestions || connectionDecline) &&
confirmationActionTokens.length > 0
) {
const expiresAt = new Date(
publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS,
).toISOString();
await tx.insert(chatActions).values(
confirmationActionTokens.map(({ actionId, decision }) => ({
companyId: interaction.companyId,
endpointId: endpoint.id,
conversationId: conversation.id,
kind: "confirmation_response",
providerActionId: actionId,
payload: {
version: 1,
publicationId: publication.id,
interactionId: interaction.id,
decision,
...(suggestions ? { selectionOptions: suggestedOptions.map(({ actionId, option }) => ({ value: actionId, optionId: option.id })) } : {}),
...(checklist ? { selectionOptions: checklistOptions.map(({ actionId, option }) => ({ value: actionId, optionId: option.id })) } : {}),
expiresAt,
},
status: "issued",
})),
);
}
return rows;
});
inserted.push(...rows);
}
return inserted;
}
/**
* Settles every delivered question or confirmation card. Providers without a
* native callback receive the same actionless terminal edit/follow-up as
* providers with buttons, so an "Open in Paperclip" prompt never remains
* visibly pending after the authoritative board decision.
*
* The terminal publication shares the provider callback idempotency key. If a
* provider click wins the race, its handler converges on the same row; if the
* Paperclip UI wins, all still-issued callback tokens expire in this same
* authoritative resolution transaction.
*/
export async function enqueueTerminalIssueInteractionChatPublications(
db: ChatPublicationDb,
interaction: IssueThreadInteraction,
) {
if (interaction.status === "pending") return [];
const originals = (
await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, interaction.companyId),
eq(chatPublications.issueId, interaction.issueId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
interaction.id,
),
),
)
).filter(
(publication) =>
publication.idempotencyKey ===
`interaction:${interaction.id}:${publication.endpointId}`,
);
if (originals.length === 0) return [];
await db
.update(chatActions)
.set({
status: "expired",
result: { code: "interaction_resolved_elsewhere" },
updatedAt: new Date(),
})
.where(
and(
eq(chatActions.companyId, interaction.companyId),
inArray(chatActions.kind, [
"photon_interaction",
"question_answer",
"question_form_open",
"question_form_submit",
"confirmation_response",
"photon_interaction",
]),
eq(chatActions.status, "issued"),
eq(
sql<string>`${chatActions.payload}->>'interactionId'`,
interaction.id,
),
),
);
const unsentIds = originals
.filter(
(publication) =>
publication.state === "pending" || publication.state === "retry",
)
.map((publication) => publication.id);
if (unsentIds.length > 0) {
await db.transaction(async (tx) => {
await notifyChatPublicationWork(tx);
return tx
.update(chatPublications)
.set({
state: "cancelled",
nextAttemptAt: null,
redactedError: "Interaction was resolved before provider publication",
updatedAt: new Date(),
})
.where(
and(
inArray(chatPublications.id, unsentIds),
inArray(chatPublications.state, ["pending", "retry"]),
),
);
});
}
const originalIds = originals.map((publication) => publication.id);
// The dispatcher may claim pending -> streaming after the first read but
// before the cancellation CAS. Re-read every executable original after that
// CAS so a loser is treated as potentially provider-visible and receives a
// terminal replacement instead of being decided from the stale snapshot.
// A published row without a provider id is also potentially visible: that
// is the durable result of an operator choosing `mark_delivered` after an
// ambiguous send, so resolution must post a terminal follow-up rather than
// leaving the external card looking actionable forever.
const currentOriginals =
originalIds.length > 0
? await db
.select()
.from(chatPublications)
.where(inArray(chatPublications.id, originalIds))
: [];
const providerVisibleOriginals = currentOriginals.filter(
(original) =>
original.state === "streaming" ||
original.state === "delivery_unknown" ||
original.state === "published",
);
const planTarget =
interaction.kind === "request_confirmation" &&
interaction.payload.target?.type === "issue_document" &&
interaction.payload.target.issueId === interaction.issueId &&
interaction.payload.target.key === "plan"
? interaction.payload.target
: null;
const rejectedPlanNeedsRevision =
interaction.status === "rejected" && planTarget !== null;
const resolutionOutcome = (interaction.result as { outcome?: unknown } | null)
?.outcome;
const continuationWakeRequired =
(interaction.kind === "ask_user_questions" || interaction.kind === "request_confirmation" || interaction.kind === "request_item_verdicts") &&
interaction.status !== "expired" &&
resolutionOutcome !== "skipped" &&
!(
interaction.kind === "ask_user_questions" &&
interaction.status === "answered"
) &&
(interaction.continuationPolicy === "wake_assignee" ||
(interaction.continuationPolicy === "wake_assignee_on_accept" &&
(interaction.status === "accepted" ||
interaction.status === "answered")) ||
rejectedPlanNeedsRevision);
if (continuationWakeRequired && currentOriginals.length > 0) {
const issue = await db
.select({
assigneeAgentId: issues.assigneeAgentId,
status: issues.status,
})
.from(issues)
.where(
and(
eq(issues.companyId, interaction.companyId),
eq(issues.id, interaction.issueId),
),
)
.then((rows) => rows[0] ?? null);
const requestedByActorType = interaction.resolvedByUserId
? "user"
: interaction.resolvedByAgentId
? "agent"
: "system";
const requestedByActorId =
interaction.resolvedByUserId ??
interaction.resolvedByAgentId ??
"system:interaction-resolution";
const wakeBinding = currentOriginals[0]!;
if (
issue?.assigneeAgentId &&
issue.status !== "done" &&
issue.status !== "cancelled" &&
!(
resolutionOutcome === "withdrawn" &&
interaction.resolvedByAgentId === issue.assigneeAgentId
)
) {
await db
.insert(chatActions)
.values({
companyId: interaction.companyId,
endpointId: wakeBinding.endpointId,
conversationId: wakeBinding.conversationId,
kind: "interaction_wakeup",
providerActionId: `interaction_wakeup:${interaction.id}`,
payload: {
version: 1,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
issueId: interaction.issueId,
agentId: issue.assigneeAgentId,
sourceCommentId: interaction.sourceCommentId ?? null,
sourceRunId: interaction.sourceRunId ?? null,
requestedByActorType,
requestedByActorId,
...(planTarget
? {
planReviewInteraction: {
id: interaction.id,
kind: interaction.kind,
status: interaction.status,
target: planTarget,
acceptedTargetRevision:
interaction.status === "accepted" ? planTarget : null,
result: interaction.result,
},
}
: {}),
...(interaction.status === "accepted" && planTarget
? {
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}
: {}),
...(interaction.resolvedByUserId
? { requestedByUserId: interaction.resolvedByUserId }
: {}),
},
status: "issued",
})
.onConflictDoNothing();
}
}
const copy = terminalNativeInteractionCopy(interaction);
if (!copy) return [];
const inserted: Array<typeof chatPublications.$inferSelect> = [];
for (const original of providerVisibleOriginals) {
const card = original.payload.card;
if (!card) continue;
const rows = await db.transaction(async (tx) => {
await notifyChatPublicationWork(tx);
return tx
.insert(chatPublications)
.values({
companyId: interaction.companyId,
endpointId: original.endpointId,
conversationId: original.conversationId,
issueId: interaction.issueId,
idempotencyKey: `interaction-resolution:${interaction.id}:${original.endpointId}`,
payload: projectSafeChatPublication({
classification: "external",
source: "issue_interaction",
text: copy.text,
interaction: {
id: interaction.id,
card: {
kind: card.kind,
title: card.title,
body: copy.body,
actions: [],
},
},
}),
state: "pending",
})
.onConflictDoNothing()
.returning();
});
inserted.push(...rows);
}
return inserted;
}
/** Prevents an unstarted stale question card from being sent after replacement. */
export async function cancelPendingIssueInteractionChatPublications(
db: ChatPublicationDb,
input: {
companyId: string;
issueId: string;
interactionIds: readonly string[];
},
) {
if (input.interactionIds.length === 0) return [];
await db
.update(chatActions)
.set({
status: "expired",
result: { code: "interaction_superseded_before_publication" },
updatedAt: new Date(),
})
.where(
and(
eq(chatActions.companyId, input.companyId),
inArray(chatActions.kind, [
"question_answer",
"question_form_open",
"question_form_submit",
"confirmation_response",
"photon_interaction",
]),
eq(chatActions.status, "issued"),
inArray(sql<string>`${chatActions.payload}->>'interactionId'`, [
...input.interactionIds,
]),
),
);
return db.transaction(async (tx) => {
await notifyChatPublicationWork(tx);
return tx
.update(chatPublications)
.set({
state: "cancelled",
nextAttemptAt: null,
redactedError: "Interaction was superseded before publication",
updatedAt: new Date(),
})
.where(
and(
eq(chatPublications.companyId, input.companyId),
eq(chatPublications.issueId, input.issueId),
inArray(chatPublications.state, ["pending", "retry"]),
inArray(sql<string>`${chatPublications.payload}->>'interactionId'`, [
...input.interactionIds,
]),
),
)
.returning();
});
}