mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
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>
This commit is contained in:
1 parent
b7506671b9
commit
7ae81c9fa7
43 files changed
+2171
-416
No files matched your search
@@ -40886,6 +40886,46 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await service.shutdown();
|
||||
});
|
||||
|
||||
it.each(["request_confirmation", "request_checkbox_confirmation", "request_item_verdicts", "suggest_tasks", "connection_intent"] as const)("delivers and replaces a Slack %s card through the publication service", async kind => {
|
||||
const previousPublicUrl = process.env.PAPERCLIP_PUBLIC_URL;
|
||||
process.env.PAPERCLIP_PUBLIC_URL = "https://paperclip.example";
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, service } = await configuredSlackEndpoint(fixture);
|
||||
try {
|
||||
const thread = makeThread({ channelId: "C-REVIEW", id: "slack:C-REVIEW:4500.1", name: "reviews" });
|
||||
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, message: makeMessage({ id: "4500.1", text: "@maya review this change", mentioned: true, userId: `U-REVIEW-${randomUUID()}` }), trigger: "mention" });
|
||||
await qualifySetupRoundTrip(service, endpoint.id);
|
||||
await service.test(endpoint.id, "owner-user");
|
||||
const [conversation] = await readChatConversationsByEndpointId(endpoint.id);
|
||||
const sourceRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId: fixture.companyId, agentId: fixture.assignedAgentId, status: "succeeded", responsibleUserId: "owner-user", contextSnapshot: { issueId: conversation.issueId, taskId: conversation.issueId, source: "automation" } });
|
||||
const interactions = issueThreadInteractionService(db);
|
||||
const issue = { id: conversation.issueId, companyId: fixture.companyId };
|
||||
const interaction = kind === "connection_intent"
|
||||
? await interactions.createConnectionIntent(issue, { sourceRunId, addresseeUserId: "owner-user", idempotencyKey: `slack-review:${sourceRunId}`, payload: { version: 1, serviceSlug: "notion", serviceName: "Notion", requestingAgentId: fixture.assignedAgentId, requestingAgentName: "Maya", phase: "requested" } })
|
||||
: await interactions.create(issue, { kind, sourceRunId, payload: kind === "suggest_tasks" ? { version: 1, tasks: [{ clientKey: "docs", title: "Review migration notes" }] } : kind === "request_item_verdicts" ? { version: 1, prompt: "Review migration notes", items: [{ id: "docs", label: "Migration notes" }] } : { version: 1, prompt: "Review migration notes", ...(kind === "request_checkbox_confirmation" ? { options: [{ id: "docs", label: "Migration notes" }] } : {}) } } as Parameters<typeof interactions.create>[1], { agentId: fixture.assignedAgentId });
|
||||
await service.processPendingPublications();
|
||||
const rows = () => db.select().from(chatPublications).where(and(eq(chatPublications.endpointId, endpoint.id), eq(sql<string>`${chatPublications.payload}->>'interactionId'`, interaction.id)));
|
||||
const [original] = await rows();
|
||||
expect(original?.state).toBe("published");
|
||||
expect(original?.payload.card?.actions).toEqual(expect.arrayContaining([expect.objectContaining({ type: "callback" })]));
|
||||
if (kind === "connection_intent") expect(original?.payload.card?.actions).toContainEqual({ type: "link", label: "Review connection", url: expect.any(String) });
|
||||
if (kind === "connection_intent") await interactions.resolveConnectionIntent(issue, interaction.id, { version: 1, outcome: "declined" }, { userId: "owner-user" });
|
||||
else if (kind === "request_item_verdicts") await interactions.submitItemVerdicts(issue, interaction.id, { verdicts: [{ id: "docs", verdict: "approve" }] }, { userId: "owner-user" });
|
||||
else await interactions.rejectInteraction(issue, interaction.id, {}, { userId: "owner-user" });
|
||||
await service.processPendingPublications();
|
||||
const publications = await rows();
|
||||
const terminal = publications.find(row => row.id !== original.id);
|
||||
expect(terminal?.state).toBe("published");
|
||||
expect(terminal?.providerMessageId).toBe(original.providerMessageId);
|
||||
expect(terminal?.payload.card?.actions).toBeUndefined();
|
||||
} finally {
|
||||
await service.shutdown();
|
||||
if (previousPublicUrl === undefined) delete process.env.PAPERCLIP_PUBLIC_URL;
|
||||
else process.env.PAPERCLIP_PUBLIC_URL = previousPublicUrl;
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes a closed-choice question, settles its Slack card, and delivers its exact continuation response", async () => {
|
||||
const fixture = await seedCompany();
|
||||
const continuationRunId = randomUUID();
|
||||
@@ -41944,10 +41984,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
};
|
||||
expect(metadata).toEqual({ c: expect.any(String), m: view.callback_id });
|
||||
const select = view.blocks.find(
|
||||
(block) => block.element.type === "static_select",
|
||||
(block) => block.element?.type === "radio_buttons",
|
||||
)!;
|
||||
const text = view.blocks.find(
|
||||
(block) => block.element.type === "plain_text_input",
|
||||
(block) => block.element?.type === "plain_text_input",
|
||||
)!;
|
||||
const production = select.element.options!.find(
|
||||
(option) => option.text.text === "Production",
|
||||
@@ -41966,7 +42006,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
values: {
|
||||
[select.block_id]: {
|
||||
[select.element.action_id]: {
|
||||
type: "static_select",
|
||||
type: "radio_buttons",
|
||||
selected_option: production,
|
||||
},
|
||||
},
|
||||
@@ -42631,7 +42671,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
privateMetadata?: string;
|
||||
};
|
||||
const selectField = modal.children.find(
|
||||
(child) => child.type === "select",
|
||||
(child) => child.type === (provider === "slack" ? "radio_select" : "select"),
|
||||
);
|
||||
const textField = modal.children.find(
|
||||
(child) => child.type === "text_input",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
applyPendingMigrations,
|
||||
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatEndpoints,
|
||||
@@ -27,7 +29,10 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
|
||||
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_CHAT_PUBLICATIONS_TEST_DATABASE_URL");
|
||||
const embeddedPostgresSupport = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
||||
? describe
|
||||
: describe.skip;
|
||||
@@ -43,16 +48,16 @@ describeEmbeddedPostgres(
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_PUBLIC_URL = "https://paperclip.example";
|
||||
tempDb = await startEmbeddedPostgresTestDatabase(
|
||||
"paperclip-terminal-chat-interaction-",
|
||||
);
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
if (externalUrl) await applyPendingMigrations(externalUrl);
|
||||
else tempDb = await startEmbeddedPostgresTestDatabase("paperclip-terminal-chat-interaction-");
|
||||
db = createDb(externalUrl ?? tempDb!.connectionString);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousPublicUrl === undefined)
|
||||
delete process.env.PAPERCLIP_PUBLIC_URL;
|
||||
else process.env.PAPERCLIP_PUBLIC_URL = previousPublicUrl;
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
@@ -230,6 +235,24 @@ describeEmbeddedPostgres(
|
||||
};
|
||||
}
|
||||
|
||||
it("publishes a larger Slack choice as an inline selection backed by durable answer tokens", async () => {
|
||||
const fixture = await seedBoundIssue(["slack", "microsoft-teams"]);
|
||||
const interaction = await issueThreadInteractionService(db).create(
|
||||
{ id: fixture.issueId, companyId: fixture.companyId },
|
||||
{ kind: "ask_user_questions", payload: { version: 1, questions: [{
|
||||
id: "fixture", prompt: "Which fixture?", selectionMode: "single", options: Array.from({ length: 13 }, (_, i) => ({ id: `fixture-${i}`, label: `Fixture ${i + 1}` })),
|
||||
}] } }, { agentId: fixture.agentId },
|
||||
);
|
||||
const publications = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
const native = publications.find(row => row.payload.card?.selection);
|
||||
expect(native?.payload.card?.selection?.options).toHaveLength(13);
|
||||
expect(native?.payload.card?.actions).toBeUndefined();
|
||||
expect(native?.payload.card?.body).not.toContain("- Fixture");
|
||||
const tokens = await db.select().from(chatActions).where(eq(chatActions.endpointId, native!.endpointId));
|
||||
expect(tokens.filter(token => token.kind === "question_answer")).toHaveLength(13);
|
||||
expect(publications.find(row => row.id !== native!.id)?.payload.card?.actions).toEqual([expect.objectContaining({ label: "Respond" })]);
|
||||
});
|
||||
|
||||
it("shows the free-text question and context alongside Respond", async () => {
|
||||
const fixture = await seedBoundIssue(["slack"]);
|
||||
const interaction = await issueThreadInteractionService(db).create(
|
||||
@@ -259,6 +282,96 @@ describeEmbeddedPostgres(
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["text", "single_select", "multi_select", "custom", "pattern", "oversized"] as const)("publishes the complete %s question with an actionable response path", async shape => {
|
||||
const fixture = await seedBoundIssue(["slack"]);
|
||||
const question = {
|
||||
id: "agent", prompt: "Which agent failed?", helpText: "Send the link and error text.", required: true,
|
||||
answerMode: shape === "multi_select" ? "multi_select" as const : shape === "text" || shape === "pattern" || shape === "oversized" ? "text" as const : "single_select" as const,
|
||||
...(shape === "single_select" || shape === "multi_select" || shape === "custom" ? { options: [{ id: "codie", label: "Codie", description: "Native runner", recommended: true }, { id: "claude", label: "Claude", description: "Legacy runner" }] } : {}),
|
||||
...(shape === "custom" ? { customAnswer: { enabled: true, label: "Another agent", placeholder: "Paste the agent link" } } : {}),
|
||||
...(shape === "pattern" ? { textValidation: { pattern: "^[A-Z]+$" } } : {}),
|
||||
...(shape === "oversized" ? { initialText: "x".repeat(3001) } : {}),
|
||||
};
|
||||
const service = issueThreadInteractionService(db);
|
||||
const interaction = await service.create({ id: fixture.issueId, companyId: fixture.companyId }, {
|
||||
kind: "ask_user_questions", title: "Identify the failed change",
|
||||
payload: { version: 1, questionSet: { schema: "paperclip.question_set.v1", description: "The answer determines the next test.", questions: [question] } },
|
||||
} as Parameters<typeof service.create>[1], { agentId: fixture.agentId });
|
||||
const [publication] = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
expect(publication.payload.card?.body).toContain(question.prompt);
|
||||
expect(publication.payload.card?.body).toContain(question.helpText);
|
||||
expect(publication.payload.card?.body).toContain("The answer determines the next test.");
|
||||
expect(publication.payload.text).toContain(question.prompt);
|
||||
if (shape === "multi_select") {
|
||||
expect(publication.payload.card?.selection).toMatchObject({ multiple: true, options: [
|
||||
{ label: "Codie (recommended)", description: "Native runner" }, { label: "Claude", description: "Legacy runner" },
|
||||
] });
|
||||
} else if (question.options && shape !== "custom") {
|
||||
expect(publication.payload.card?.body).toContain("Codie (recommended): Native runner");
|
||||
expect(publication.payload.card?.body).toContain("Claude: Legacy runner");
|
||||
}
|
||||
if (shape === "custom") {
|
||||
expect(publication.payload.card?.body).toContain("- Codie (recommended): Native runner");
|
||||
expect(publication.payload.card?.actions?.map(action => action.label)).toEqual(["Codie", "Claude", "Other…"]);
|
||||
const tokens = await db.select().from(chatActions).where(eq(chatActions.endpointId, publication.endpointId));
|
||||
expect(tokens.map(token => token.kind).sort()).toEqual(["question_answer", "question_answer", "question_form_open", "question_form_submit"].sort());
|
||||
}
|
||||
if (shape === "oversized") {
|
||||
expect(publication.payload.card?.actions).toEqual([{ type: "link", label: "Open in Paperclip", url: expect.stringContaining(fixture.issueId) }]);
|
||||
} else if (shape !== "multi_select") {
|
||||
expect(publication.payload.card?.actions?.every(action => action.type === "callback")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([100, 101, 128])("preserves the complete response path for %i choices plus Other", async count => {
|
||||
const fixture = await seedBoundIssue(["slack"]);
|
||||
const service = issueThreadInteractionService(db);
|
||||
const options = Array.from({ length: count }, (_, index) => ({ id: `choice-${index}`, label: `Choice ${index}` }));
|
||||
const interaction = await service.create({ id: fixture.issueId, companyId: fixture.companyId }, {
|
||||
kind: "ask_user_questions",
|
||||
payload: { version: 1, questionSet: { schema: "paperclip.question_set.v1", questions: [{
|
||||
id: "choice", prompt: "Choose a fixture", answerMode: "single_select", required: true,
|
||||
options, customAnswer: { enabled: true },
|
||||
}] } },
|
||||
}, { agentId: fixture.agentId });
|
||||
const [publication] = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
const tokens = await db.select().from(chatActions).where(eq(chatActions.endpointId, publication.endpointId));
|
||||
if (count === 100) {
|
||||
expect(publication.payload.card?.selection?.options).toHaveLength(count);
|
||||
expect(publication.payload.card?.actions).toEqual([
|
||||
expect.objectContaining({ type: "callback", label: "Other…" }),
|
||||
]);
|
||||
expect(tokens.filter(token => token.kind === "question_answer")).toHaveLength(count);
|
||||
} else {
|
||||
expect(publication.payload.card?.selection).toBeUndefined();
|
||||
expect(publication.payload.card?.actions).toEqual([
|
||||
{ type: "link", label: "Open in Paperclip", url: expect.stringContaining(fixture.issueId) },
|
||||
]);
|
||||
expect(tokens).toEqual([]);
|
||||
await markInteractionCardsPublished(fixture.companyId, interaction.id);
|
||||
await service.answerQuestions({ id: fixture.issueId, companyId: fixture.companyId }, interaction.id, {
|
||||
answers: [{ questionId: "choice", optionIds: [options[count - 1]!.id] }],
|
||||
}, { userId: "board-user" });
|
||||
const settled = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
expect(settled.find(row => row.id !== publication.id)?.payload.card?.body).toContain(`Answered: Choice ${count - 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps repeated choices and help under each question", async () => {
|
||||
const fixture = await seedBoundIssue(["slack"]);
|
||||
const interaction = await issueThreadInteractionService(db).create(
|
||||
{ id: fixture.issueId, companyId: fixture.companyId },
|
||||
{ kind: "ask_user_questions", payload: { version: 1, questions: ["Codie", "Claude"].map(agent => ({ id: agent, prompt: `Convert ${agent}?`, helpText: "Keep the existing configuration.", selectionMode: "single" as const, options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] })) } },
|
||||
{ agentId: fixture.agentId },
|
||||
);
|
||||
const [publication] = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
const body = publication.payload.card!.body!;
|
||||
expect(body.match(/- Yes/g)).toHaveLength(2);
|
||||
expect(body.match(/- No/g)).toHaveLength(2);
|
||||
expect(body.match(/Keep the existing configuration/g)).toHaveLength(2);
|
||||
expect(body.slice(body.indexOf("Convert Claude?"))).toContain("- Yes");
|
||||
});
|
||||
|
||||
it("publishes prompts only for the endpoint's verified immutable agent", async () => {
|
||||
const fixture = await seedBoundIssue();
|
||||
const service = issueThreadInteractionService(db);
|
||||
@@ -371,7 +484,7 @@ describeEmbeddedPostgres(
|
||||
expect(await publicationsForInteraction(fixture.companyId, interaction.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps unsupported governance interactions authoritative in Paperclip", async () => {
|
||||
it("delivers native checkbox reviews in Slack", async () => {
|
||||
const fixture = await seedBoundIssue();
|
||||
const interaction = await issueThreadInteractionService(db).create(
|
||||
{ id: fixture.issueId, companyId: fixture.companyId },
|
||||
@@ -386,9 +499,84 @@ describeEmbeddedPostgres(
|
||||
{ agentId: fixture.agentId },
|
||||
);
|
||||
|
||||
await expect(
|
||||
publicationsForInteraction(fixture.companyId, interaction.id),
|
||||
).resolves.toEqual([]);
|
||||
const publications = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
expect(publications).toHaveLength(1);
|
||||
expect(publications[0].endpointId).toBe(fixture.endpointIds.slack);
|
||||
expect(publications[0].payload.card).toMatchObject({
|
||||
selection: { multiple: true, options: [{ label: "US" }] },
|
||||
actions: [{ type: "callback", label: "Reject" }],
|
||||
});
|
||||
await markInteractionCardsPublished(fixture.companyId, interaction.id);
|
||||
await issueThreadInteractionService(db).rejectInteraction(
|
||||
{ id: fixture.issueId, companyId: fixture.companyId }, interaction.id, {}, { userId: "board-user" },
|
||||
);
|
||||
const settled = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
expect(settled).toHaveLength(2);
|
||||
expect(settled.find(row => row.id !== publications[0].id)?.payload.card?.body).toBe("Rejected");
|
||||
expect(settled.find(row => row.id !== publications[0].id)?.payload.card?.actions).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "suggest_tasks", payload: { version: 1, tasks: [{ clientKey: "docs", title: "Review the migration", description: "Verify preserved configuration" }] }, expected: "Review the migration" },
|
||||
{ kind: "request_item_verdicts", payload: { version: 1, prompt: "Review each change", items: [{ id: "docs", label: "Migration notes", description: "Verify preserved configuration" }] }, expected: "Migration notes" },
|
||||
] as const)("delivers and settles $kind reviews in Slack", async ({ kind, payload, expected }) => {
|
||||
const fixture = await seedBoundIssue();
|
||||
const service = issueThreadInteractionService(db);
|
||||
const issue = { id: fixture.issueId, companyId: fixture.companyId };
|
||||
const interaction = await service.create(issue, { kind, payload } as Parameters<typeof service.create>[1], { agentId: fixture.agentId });
|
||||
const [original] = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
expect(original?.endpointId).toBe(fixture.endpointIds.slack);
|
||||
expect(original?.payload.card?.body).toContain(expected);
|
||||
expect(original?.payload.card?.body).toContain("Verify preserved configuration");
|
||||
expect(original?.payload.card?.actions).toEqual(kind === "request_item_verdicts" ? [{ type: "callback", actionId: expect.any(String), label: "Review decisions", style: "primary" }] : [{ type: "callback", actionId: expect.any(String), label: "Create task", style: "primary" }, { type: "callback", actionId: expect.any(String), label: "Dismiss", style: "default" }, { type: "link", label: "Revise in Paperclip", url: expect.stringContaining(fixture.issueId) }]);
|
||||
await markInteractionCardsPublished(fixture.companyId, interaction.id);
|
||||
if (kind === "request_item_verdicts") {
|
||||
await service.submitItemVerdicts(issue, interaction.id, { verdicts: [{ id: "docs", verdict: "approve" }] }, { userId: "board-user" });
|
||||
} else {
|
||||
await service.rejectInteraction(issue, interaction.id, {}, { userId: "board-user" });
|
||||
}
|
||||
const settled = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
expect(settled).toHaveLength(2);
|
||||
expect(settled.find(row => row.id !== original.id)?.payload.card?.body).toBe(kind === "request_item_verdicts" ? "Migration notes: approve" : "Dismissed — no tasks created.");
|
||||
expect(settled.find(row => row.id !== original.id)?.payload.card?.actions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("settles an item review superseded by a historical human comment", async () => {
|
||||
const fixture = await seedBoundIssue(["slack"]);
|
||||
const service = issueThreadInteractionService(db);
|
||||
const issue = { id: fixture.issueId, companyId: fixture.companyId };
|
||||
const interaction = await service.create(issue, { kind: "request_item_verdicts", payload: { version: 1, prompt: "Review these items", supersedeOnUserComment: true, items: [{ id: "docs", label: "Migration notes" }] } }, { agentId: fixture.agentId });
|
||||
const [original] = await markInteractionCardsPublished(fixture.companyId, interaction.id);
|
||||
await db.insert(issueComments).values({ companyId: fixture.companyId, issueId: fixture.issueId, authorUserId: "board-user", body: "Use the updated proposal instead.", createdAt: new Date(Date.now() + 1000) });
|
||||
const expired = await service.expireRequestConfirmationsSupersededByHistoricalComments(issue);
|
||||
expect(expired).toHaveLength(1);
|
||||
expect(expired[0].status).toBe("expired");
|
||||
const publications = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
expect(publications).toHaveLength(2);
|
||||
expect(publications.find(row => row.id !== original.id)?.payload.card?.body).toBe("Expired in Paperclip");
|
||||
});
|
||||
|
||||
it.each(["decline", "reassign"] as const)("delivers, deduplicates and settles an addressed connection request on %s without a second wake owner", async outcome => {
|
||||
const fixture = await seedBoundIssue();
|
||||
const sourceRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId: fixture.companyId, agentId: fixture.agentId, status: "running", responsibleUserId: "board-user", contextSnapshot: { issueId: fixture.issueId } });
|
||||
const service = issueThreadInteractionService(db);
|
||||
const issue = { id: fixture.issueId, companyId: fixture.companyId };
|
||||
const input = { sourceRunId, addresseeUserId: "board-user", idempotencyKey: "connection-slack", payload: { version: 1 as const, serviceSlug: "notion", serviceName: "Notion", requestingAgentId: fixture.agentId, requestingAgentName: "Codie", phase: "requested" as const } };
|
||||
const interaction = await service.createConnectionIntent(issue, input);
|
||||
await service.createConnectionIntent(issue, input);
|
||||
const [original] = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
expect(original?.payload.card?.body).toContain("Codie needs Notion");
|
||||
expect(original?.payload.card?.actions).toEqual([{ type: "link", label: "Review connection", url: expect.stringContaining(fixture.issueId) }, { type: "callback", actionId: expect.any(String), label: "Not now", style: "default" }]);
|
||||
expect(await publicationsForInteraction(fixture.companyId, interaction.id)).toHaveLength(1);
|
||||
await markInteractionCardsPublished(fixture.companyId, interaction.id);
|
||||
await expect(service.resolveConnectionIntent(issue, interaction.id, { version: 1, outcome: "declined" }, { userId: "different-user" })).rejects.toMatchObject({ status: 403 });
|
||||
if (outcome === "decline") await service.resolveConnectionIntent(issue, interaction.id, { version: 1, outcome: "declined" }, { userId: "board-user" });
|
||||
else await db.transaction(async tx => issueThreadInteractionService(tx as unknown as typeof db).expireConnectionIntentsForOwnershipChange(issue));
|
||||
const settled = await publicationsForInteraction(fixture.companyId, interaction.id);
|
||||
expect(settled).toHaveLength(2);
|
||||
expect(settled.find(row => row.id !== original.id)?.payload.card?.body).toBe(outcome === "decline" ? "Declined: Notion. No access was granted." : "Expired in Paperclip");
|
||||
expect((await db.select().from(chatActions).where(eq(chatActions.companyId, fixture.companyId))).filter(action => action.kind === "interaction_wakeup")).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles a card when the dispatcher claims it between the read and cancellation CAS", async () => {
|
||||
@@ -532,7 +720,7 @@ describeEmbeddedPostgres(
|
||||
]);
|
||||
});
|
||||
|
||||
it("settles form answers without claiming a surface or exposing free text", async () => {
|
||||
it("retains question and answer context while redacting credentials in settled form cards", async () => {
|
||||
const fixture = await seedBoundIssue();
|
||||
const service = issueThreadInteractionService(db);
|
||||
const interaction = await service.create(
|
||||
@@ -573,7 +761,7 @@ describeEmbeddedPostgres(
|
||||
{
|
||||
questionId: "label",
|
||||
optionIds: [],
|
||||
otherText: "private free-text label",
|
||||
otherText: "Runner conversion failed. password=do-not-publish-this",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -585,12 +773,12 @@ describeEmbeddedPostgres(
|
||||
expect(settlements).toHaveLength(2);
|
||||
for (const settlement of settlements) {
|
||||
expect(settlement.payload).toMatchObject({
|
||||
text: "Answered.",
|
||||
card: { kind: "question", body: "Answered." },
|
||||
text: expect.stringContaining("Enter a label\n\nAnswered: Runner conversion failed."),
|
||||
card: { kind: "question", body: expect.stringContaining("Choose a tree\n\nAnswered: Cedar") },
|
||||
});
|
||||
expect(settlement.payload.card?.actions ?? []).toHaveLength(0);
|
||||
expect(JSON.stringify(settlement.payload)).not.toContain(
|
||||
"private free-text label",
|
||||
"do-not-publish-this",
|
||||
);
|
||||
expect(JSON.stringify(settlement.payload)).not.toContain(
|
||||
"Answered in Paperclip",
|
||||
@@ -775,7 +963,8 @@ describeEmbeddedPostgres(
|
||||
originals.find((row) => row.endpointId === fixture.endpointIds.slack)
|
||||
?.payload.card?.actions,
|
||||
).toEqual([
|
||||
expect.objectContaining({ type: "link", label: "Open in Paperclip" }),
|
||||
expect.objectContaining({ type: "callback", label: "Accept" }),
|
||||
expect.objectContaining({ type: "callback", label: "Reject" }),
|
||||
]);
|
||||
expect(
|
||||
originals.find(
|
||||
@@ -991,7 +1180,7 @@ describeEmbeddedPostgres(
|
||||
);
|
||||
expect(
|
||||
publications.some((row) =>
|
||||
row.payload.card?.actions?.some((action) => action.type === "link"),
|
||||
row.payload.card?.actions?.some((action) => action.type === "callback"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("scheduled continuation presentation", () => {
|
||||
});
|
||||
|
||||
describe("continuation origin verification", () => {
|
||||
it.each(["native_status_decision", "issue.monitor", "tool_action_review", "issue.comment", "issue.comment.created", "issue.update"])
|
||||
it.each(["native_status_decision", "issue.monitor", "tool_action_review", "issue.comment", "issue.comment.created", "issue.update", "issue.interaction.respond", "issue.interaction.accept", "issue.interaction.reject", "issue.interaction.verdicts", "issue.interaction.cancel", "issue.interaction.withdraw", "external_chat.interaction.resolve", "connection_intent.resolved"])
|
||||
("checks %s without treating candidacy as publication authority", source => {
|
||||
expect(isChatOriginPublicationCandidate({ source })).toBe(true);
|
||||
expect(isExternalChatPresentationContext({ source })).toBe(false);
|
||||
|
||||
@@ -2729,6 +2729,33 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
expect((await db.select().from(issueThreadInteractions))[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it.each(["verdicts", "suggestions", "dismiss"] as const)(
|
||||
"rolls back %s when external action authorization or receipt ownership changes",
|
||||
async (kind) => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("External form transaction");
|
||||
const issue = { id: issueId, companyId };
|
||||
const interaction = await interactionsSvc.create(issue, kind === "verdicts"
|
||||
? { kind: "request_item_verdicts", payload: { version: 1, prompt: "Review", items: [{ id: "api", label: "API" }] } }
|
||||
: { kind: "suggest_tasks", payload: { version: 1, tasks: [{ clientKey: "check", title: "Check the result" }] } },
|
||||
{ userId: "local-board" });
|
||||
const resolve = (options: import("../services/issue-thread-interactions.js").InteractionResolutionMutationOptions) => kind === "verdicts"
|
||||
? interactionsSvc.submitItemVerdicts(issue, interaction.id, { verdicts: [{ id: "api", verdict: "approve" }] }, { userId: "local-board" }, options)
|
||||
: kind === "dismiss"
|
||||
? interactionsSvc.rejectInteraction(issue, interaction.id, {}, { userId: "local-board" }, options)
|
||||
: interactionsSvc.acceptInteraction(issue, interaction.id, { selectedClientKeys: ["check"] }, { userId: "local-board" }, options);
|
||||
for (const hook of ["beforeResolveInTransaction", "afterResolveInTransaction"] as const) {
|
||||
await expect(resolve({ [hook]: async () => { throw new Error("External action no longer authorized"); } })).rejects.toThrow("External action no longer authorized");
|
||||
expect((await interactionsSvc.listForIssue(issueId)).find(item => item.id === interaction.id)).toMatchObject({ status: "pending", result: null });
|
||||
expect(await db.select({ id: issues.id }).from(issues).where(eq(issues.parentId, issueId))).toEqual([]);
|
||||
}
|
||||
await resolve({});
|
||||
const resolved = (await interactionsSvc.listForIssue(issueId)).find(item => item.id === interaction.id);
|
||||
expect(resolved?.status).toBe(kind === "verdicts" ? "answered" : kind === "dismiss" ? "rejected" : "accepted");
|
||||
const children = await db.select({ id: issues.id }).from(issues).where(eq(issues.parentId, issueId));
|
||||
expect(children).toHaveLength(kind === "suggestions" ? 1 : 0);
|
||||
},
|
||||
);
|
||||
|
||||
it("submits request_item_verdicts partially and completes when all items are resolved", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Item verdict partial submit");
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { enqueueIssueInteractionChatPublications } from "../services/chat-interaction-publications.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agentWakeupRequests, agents, approvals, authUsers, companies, companyMemberships, connectionGrants, connectionGrantDelegations,
|
||||
connectionIntentDeliveries, createDb, environmentLeases, heartbeatRunEvents, heartbeatRuns, issueApprovals, issueRecoveryActions, issueThreadInteractions, issues, projects, toolApplications,
|
||||
chatConversations, chatEndpoints, chatPublications, connectionIntentDeliveries, createDb, environmentLeases, heartbeatRunEvents, heartbeatRuns, issueApprovals, issueRecoveryActions, issueThreadInteractions, issues, projects, toolApplications,
|
||||
toolCatalogEntries, toolConnectionInstalls, toolConnections, toolProfileBindings, toolProfiles, toolPolicies,
|
||||
} from "@paperclipai/db";
|
||||
import { createPostgresRunDispatchAdapter } from "../modules/run-dispatch/adapters/postgres.js";
|
||||
@@ -59,6 +60,27 @@ describePostgres("already available connection requests", () => {
|
||||
return run!;
|
||||
}
|
||||
|
||||
it("settles a published Slack card when existing access retires the connection request", async () => {
|
||||
const f = await seed();
|
||||
const endpointId = randomUUID();
|
||||
const [app] = await db.insert(toolApplications).values({ companyId: f.companyId, applicationKey: `chat:slack:${endpointId}`, name: "Slack", type: "chat", status: "active" }).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({ companyId: f.companyId, applicationId: app.id, name: "Slack channel", uid: endpointId, connectionPurpose: "channel", transport: "chat_sdk", status: "active", enabled: true }).returning();
|
||||
await db.insert(chatEndpoints).values({ id: endpointId, companyId: f.companyId, connectionId: connection.id, provider: "slack", publicId: randomUUID(), assignedAgentId: f.agentId, status: "active", capabilities: { threads: true, directMessages: true, nativeStreaming: false, messageEdits: true, messageDeletes: false, reactions: true, files: true, cards: true, actions: true, modals: true, slashCommands: true, ephemeralMessages: true, proactiveDirectMessages: true } });
|
||||
await db.insert(chatConversations).values({ companyId: f.companyId, endpointId, issueId: f.issueId, externalConversationId: "C-CONNECTION", externalThreadId: "slack:C-CONNECTION:123.1", externalLabel: "Connection setup", state: "active" });
|
||||
await db.update(issueThreadInteractions).set({ createdByAgentId: f.agentId }).where(eq(issueThreadInteractions.id, f.intent.id));
|
||||
const interaction = await issueThreadInteractionService(db).getById(f.intent.id);
|
||||
expect(interaction).not.toBeNull();
|
||||
const [original] = await enqueueIssueInteractionChatPublications(db, interaction!);
|
||||
expect(original).toBeDefined();
|
||||
await db.update(chatPublications).set({ state: "published", providerMessageId: "123.2" }).where(eq(chatPublications.id, original.id));
|
||||
expect(await satisfiedConnectionIntentService(db).sweepPending()).toMatchObject({ satisfied: 1 });
|
||||
const publications = await db.select().from(chatPublications).where(eq(chatPublications.endpointId, endpointId));
|
||||
expect(publications).toHaveLength(2);
|
||||
const terminal = publications.find(row => row.id !== original.id);
|
||||
expect(terminal?.payload.card?.body).toBe("Expired in Paperclip");
|
||||
expect(terminal?.payload.card?.actions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores an already available connection before the worker retires its card", async () => {
|
||||
const f = await seed(); const run = await retry(f);
|
||||
expect(await createPostgresRunDispatchAdapter(db).evaluateScheduledRetryGate({ companyId: f.companyId, runId: run.id, now: new Date() })).toMatchObject({ allowed: true });
|
||||
|
||||
@@ -3437,6 +3437,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
redactError, CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS, get heartbeat() { return options.heartbeat; },
|
||||
});
|
||||
const { handleAction, handleModalSubmit, reconcileTerminalConfirmationActions } = createChatInteractionCallbacks(db, {
|
||||
heartbeat: options.heartbeat,
|
||||
CAPABILITIES, MAX_ERROR_TEXT, actionThreadMatchesConversation, nonDirectDestinationAllowed, sameTeamsThreadIdentity,
|
||||
ensurePrincipal, endpointRecord, runtimeCallbackRecord, runtimeCallbackEndpoint,
|
||||
requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { slackQuestionFormValidationError } from "../slack/question-forms.js";
|
||||
import { chatInteractionForm, chatItemVerdicts } from "../../chat-interaction-forms.js";
|
||||
import { createDiscordQuestionForms } from "../discord/question-forms.js";
|
||||
|
||||
import { createProviderEffects, type ProviderEffectPayload } from "../provider-effects.js";
|
||||
@@ -38,6 +40,7 @@ import type {
|
||||
ChatAdapterCapabilities,
|
||||
ChatProvider,
|
||||
SafeChatPublicationPayload,
|
||||
IssueThreadInteraction,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
@@ -74,6 +77,8 @@ import {
|
||||
import {
|
||||
enqueueTerminalIssueInteractionChatPublications,
|
||||
nativeChatQuestion,
|
||||
nativeChatMultiQuestion,
|
||||
nativeChatCustomQuestion,
|
||||
TELEGRAM_CALLBACK_DATA_LIMIT_BYTES,
|
||||
telegramChatSdkCallbackData,
|
||||
} from "../../chat-interaction-publications.js";
|
||||
@@ -104,10 +109,11 @@ import type {
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
import { createTelegramConfirmationActions } from "../telegram/confirmation-actions.js";
|
||||
import { createChatConfirmationActions } from "./confirmation-actions.js";
|
||||
type ResourceRow = typeof chatEndpointResources.$inferSelect;
|
||||
type PrincipalResolution = { principal: typeof chatExternalPrincipals.$inferSelect; userId: string | null; linkedDenied: boolean };
|
||||
type InteractionCallbacksOptions = {
|
||||
heartbeat: import("../../issue-assignment-wakeup.js").IssueAssignmentWakeupDeps;
|
||||
CAPABILITIES: Record<ChatProvider, ChatAdapterCapabilities>;
|
||||
MAX_ERROR_TEXT: number;
|
||||
actionThreadMatchesConversation: (provider: ChatProvider, actionThreadId: string, conversationThreadId: string) => boolean;
|
||||
@@ -137,8 +143,8 @@ type InteractionCallbacksOptions = {
|
||||
export function createChatInteractionCallbacks(db: Db, options: InteractionCallbacksOptions) {
|
||||
const { CAPABILITIES, MAX_ERROR_TEXT, actionThreadMatchesConversation, nonDirectDestinationAllowed, sameTeamsThreadIdentity, ensurePrincipal, endpointRecord, runtimeCallbackRecord, runtimeCallbackEndpoint, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, recordCurrentMicrosoftTeamsRoute, lockTelegramCallbackNoticeAuthority, stageProviderEffect, scheduleProviderEffect, invalidateRuntime, redactError, persistence, questionResponses, scheduleMessageProcessing, processPendingPublications } = options;
|
||||
const { handleDiscordFormCorrection, acceptDiscordQuestionForm } = createDiscordQuestionForms(db, { requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, persistence });
|
||||
const { reconcileTerminalConfirmationActions, handleTelegramConfirmation } = createTelegramConfirmationActions(db, {
|
||||
requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications,
|
||||
const { reconcileTerminalConfirmationActions, handleChatConfirmation } = createChatConfirmationActions(db, {
|
||||
heartbeat: options.heartbeat, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications,
|
||||
get confirmationResolutionPersistBarrier() { return options.confirmationResolutionPersistBarrier; },
|
||||
});
|
||||
|
||||
@@ -896,10 +902,10 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
(!correction &&
|
||||
event.provider !== "telegram" &&
|
||||
event.event.value !== payload.interactionId) ||
|
||||
!payload.card?.actions?.some(
|
||||
(action) =>
|
||||
action.type === "callback" && action.actionId === sourceActionId,
|
||||
)
|
||||
!(payload.card?.actions?.some(
|
||||
(action) => action.type === "callback" && action.actionId === sourceActionId,
|
||||
) || payload.card?.selection?.options.some(option => option.actionId === sourceActionId) ||
|
||||
payload.card?.selection?.multiple && payload.card.selection.actionId === sourceActionId)
|
||||
) {
|
||||
return deny(safelyKnown);
|
||||
}
|
||||
@@ -956,12 +962,11 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
}
|
||||
return deny(safelyKnown);
|
||||
}
|
||||
if (interaction.kind === "request_confirmation") {
|
||||
return handleTelegramConfirmation({ record, event, runtimeContext, conversation, principal: { ...principal, userId: principal.userId }, issued, interaction, deny, safelyKnown });
|
||||
}
|
||||
if (interaction.kind !== "ask_user_questions") {
|
||||
return deny(safelyKnown);
|
||||
if (interaction.kind === "request_confirmation" || event.provider === "slack" && (interaction.kind === "request_checkbox_confirmation" || interaction.kind === "suggest_tasks" || interaction.kind === "connection_intent")) {
|
||||
return handleChatConfirmation({ record, event, runtimeContext, conversation, principal: { ...principal, userId: principal.userId }, issued, interaction, deny, safelyKnown });
|
||||
}
|
||||
const formInteraction = chatInteractionForm(interaction, event.provider);
|
||||
if (!formInteraction) return deny(safelyKnown);
|
||||
if (isChatQuestionFormOpenActionId(sourceActionId)) {
|
||||
if (
|
||||
event.provider !== "slack" &&
|
||||
@@ -1001,7 +1006,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
companyId: record.endpoint.companyId,
|
||||
endpointId: record.endpoint.id,
|
||||
conversationId: conversation.id,
|
||||
interaction,
|
||||
interaction: formInteraction,
|
||||
openActionId: sourceActionId,
|
||||
});
|
||||
if (!resolved || resolved.publicationId !== issued.publication.id) {
|
||||
@@ -1167,17 +1172,28 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
tokenPayload.publicationId !== issued.publication.id ||
|
||||
tokenPayload.interactionId !== payload.interactionId ||
|
||||
typeof tokenPayload.questionId !== "string" ||
|
||||
typeof tokenPayload.optionId !== "string"
|
||||
(typeof tokenPayload.optionId !== "string" && !Array.isArray(tokenPayload.selectionOptions))
|
||||
) {
|
||||
return deny(safelyKnown);
|
||||
}
|
||||
|
||||
const question = nativeChatQuestion(interaction);
|
||||
const option = question?.options.find(
|
||||
(candidate) => candidate.id === tokenPayload.optionId,
|
||||
);
|
||||
if (!question || question.id !== tokenPayload.questionId || !option)
|
||||
return deny(safelyKnown);
|
||||
if (interaction.kind !== "ask_user_questions") return deny(safelyKnown);
|
||||
const multiple = event.provider === "slack" && Array.isArray(tokenPayload.selectionOptions);
|
||||
const question = multiple ? nativeChatMultiQuestion(interaction) : nativeChatQuestion(interaction, event.provider === "slack" ? 100 : undefined) ?? (event.provider === "slack" ? nativeChatCustomQuestion(interaction) : null);
|
||||
if (!question || question.id !== tokenPayload.questionId) return deny(safelyKnown);
|
||||
const selected = multiple ? event.selectionValues : undefined;
|
||||
if (multiple && (!selected || new Set(selected).size !== selected.length)) return deny(safelyKnown);
|
||||
const optionIds = multiple ? selected!.map(value => {
|
||||
const mapping = (tokenPayload.selectionOptions as Array<{ value?: unknown; optionId?: unknown }>).find(item => item.value === value);
|
||||
return typeof mapping?.optionId === "string" ? mapping.optionId : null;
|
||||
}) : [tokenPayload.optionId as string];
|
||||
const selectedOptions = optionIds.map(id => question.options.find(option => option.id === id));
|
||||
if (selectedOptions.some(option => !option)) return deny(safelyKnown);
|
||||
if (multiple && !optionIds.length && question.required !== false) {
|
||||
await event.event.thread?.postEphemeral(event.event.user, { markdown: "Choose at least one option, then select Submit answer." }, { fallbackToDM: false });
|
||||
return;
|
||||
}
|
||||
const answerLabel = selectedOptions.map(option => option!.label).join("; ") || "No options selected";
|
||||
|
||||
await options.questionResolutionPersistBarrier?.();
|
||||
|
||||
@@ -1201,11 +1217,11 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
issue,
|
||||
interaction.id,
|
||||
{
|
||||
answers: [{ questionId: question.id, optionIds: [option.id] }],
|
||||
answers: [{ questionId: question.id, optionIds: optionIds as string[] }],
|
||||
},
|
||||
{ userId: principal.userId },
|
||||
{
|
||||
beforeResolveInTransaction: async (tx) => {
|
||||
beforeResolveInTransaction: async (tx: DbTransaction) => {
|
||||
await requireCurrentExternalActionAuthorization(tx, {
|
||||
conversationId: conversation.id,
|
||||
endpointId: record.endpoint.id,
|
||||
@@ -1214,7 +1230,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
runtimeContext,
|
||||
});
|
||||
},
|
||||
afterResolveInTransaction: async (tx, resolved) => {
|
||||
afterResolveInTransaction: async (tx: DbTransaction, resolved: IssueThreadInteraction) => {
|
||||
const [completed] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
@@ -1225,6 +1241,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
messageId: event.event.messageId,
|
||||
actionId: event.event.actionId,
|
||||
value: event.event.value ?? null,
|
||||
...(multiple ? { selectionValues: selected } : {}),
|
||||
},
|
||||
result: {
|
||||
interactionId: resolved.id,
|
||||
@@ -1298,13 +1315,13 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
payload: projectSafeChatPublication({
|
||||
classification: "external",
|
||||
source: "issue_interaction",
|
||||
text: `Answered: ${option.label}.`,
|
||||
text: `Answered: ${answerLabel}.`,
|
||||
interaction: {
|
||||
id: resolved.id,
|
||||
card: {
|
||||
kind: "question",
|
||||
title: question.prompt,
|
||||
body: `Answered: ${option.label}.`,
|
||||
body: `Answered: ${answerLabel}.`,
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
@@ -1644,6 +1661,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
const interaction = (
|
||||
await issueThreadInteractionService(db).listForIssue(conversation.issueId)
|
||||
).find((candidate) => candidate.id === loaded.interactionId);
|
||||
const formInteraction = interaction ? chatInteractionForm(interaction, event.provider) : null;
|
||||
const discordReplayAnswersMatch = (
|
||||
resolved: typeof interaction,
|
||||
): boolean => {
|
||||
@@ -1673,7 +1691,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
) =>
|
||||
interaction?.companyId === record.endpoint.companyId &&
|
||||
interaction.issueId === conversation.issueId &&
|
||||
interaction.kind === "ask_user_questions" &&
|
||||
(interaction.kind === "ask_user_questions" || event.provider === "slack" && interaction.kind === "request_item_verdicts") &&
|
||||
interaction.status === "answered" &&
|
||||
interaction.resolvedByUserId === principal.userId &&
|
||||
current?.status === "processed" &&
|
||||
@@ -1716,7 +1734,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
!interaction ||
|
||||
interaction.companyId !== record.endpoint.companyId ||
|
||||
interaction.issueId !== conversation.issueId ||
|
||||
interaction.kind !== "ask_user_questions" ||
|
||||
!formInteraction ||
|
||||
interaction.status !== "pending"
|
||||
) {
|
||||
// Another callback can commit the same token between the initial token
|
||||
@@ -1727,7 +1745,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
if (
|
||||
interaction?.companyId === record.endpoint.companyId &&
|
||||
interaction.issueId === conversation.issueId &&
|
||||
interaction.kind === "ask_user_questions" &&
|
||||
(interaction.kind === "ask_user_questions" || event.provider === "slack" && interaction.kind === "request_item_verdicts") &&
|
||||
interaction.status === "answered"
|
||||
) {
|
||||
try {
|
||||
@@ -1775,20 +1793,20 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
const validation = validateChatQuestionFormSubmission({
|
||||
callbackId: event.event.callbackId,
|
||||
privateMetadata: event.event.privateMetadata,
|
||||
interaction,
|
||||
interaction: formInteraction,
|
||||
payload: loaded.payload,
|
||||
values: event.event.values,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
if (validation.code === "invalid_form") {
|
||||
if (event.provider === "discord") {
|
||||
return handleDiscordFormCorrection({ record, event, runtimeContext, conversation, principal: { ...principal, userId: principal.userId }, loaded, originalPublication, providerMessageId, interaction, payload, validation, deny });
|
||||
return handleDiscordFormCorrection({ record, event, runtimeContext, conversation, principal: { ...principal, userId: principal.userId }, loaded, originalPublication, providerMessageId, interaction: formInteraction, payload, validation, deny });
|
||||
}
|
||||
return chatQuestionFormValidationResponse({
|
||||
provider: event.provider,
|
||||
callbackId: event.event.callbackId,
|
||||
privateMetadata: event.event.privateMetadata,
|
||||
interaction,
|
||||
interaction: formInteraction,
|
||||
payload: loaded.payload,
|
||||
values: event.event.values,
|
||||
});
|
||||
@@ -1827,19 +1845,12 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
);
|
||||
}
|
||||
await options.questionResolutionPersistBarrier?.();
|
||||
let answered: Awaited<
|
||||
ReturnType<
|
||||
ReturnType<typeof issueThreadInteractionService>["answerQuestions"]
|
||||
>
|
||||
>;
|
||||
const itemVerdicts = chatItemVerdicts(interaction, validation.answers, loaded.payload);
|
||||
if (itemVerdicts && Object.keys(itemVerdicts.errors).length) return { action: "errors", errors: itemVerdicts.errors };
|
||||
let answered: IssueThreadInteraction;
|
||||
try {
|
||||
answered = await issueThreadInteractionService(db).answerQuestions(
|
||||
issue,
|
||||
interaction.id,
|
||||
{ answers: validation.answers },
|
||||
{ userId: principal.userId },
|
||||
{
|
||||
beforeResolveInTransaction: async (tx) => {
|
||||
const mutationOptions = {
|
||||
beforeResolveInTransaction: async (tx: DbTransaction) => {
|
||||
await requireCurrentExternalActionAuthorization(tx, {
|
||||
conversationId: conversation.id,
|
||||
endpointId: event.endpointId,
|
||||
@@ -1848,7 +1859,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
runtimeContext,
|
||||
});
|
||||
},
|
||||
afterResolveInTransaction: async (tx, resolved) => {
|
||||
afterResolveInTransaction: async (tx: DbTransaction, resolved: IssueThreadInteraction) => {
|
||||
const claimed = await claimChatQuestionFormSubmission(tx, {
|
||||
actionRowId: loaded.actionRowId,
|
||||
principalId: principal.principal.id,
|
||||
@@ -1900,8 +1911,10 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
answered = itemVerdicts
|
||||
? (await issueThreadInteractionService(db).submitItemVerdicts(issue, interaction.id, { verdicts: itemVerdicts.verdicts }, { userId: principal.userId }, mutationOptions)).interaction
|
||||
: await issueThreadInteractionService(db).answerQuestions(issue, interaction.id, { answers: validation.answers }, { userId: principal.userId }, mutationOptions);
|
||||
} catch (error) {
|
||||
if (isExternalActionAuthorizationChange(error)) {
|
||||
await db.transaction(async (tx) => {
|
||||
@@ -1953,6 +1966,10 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
error && typeof error === "object"
|
||||
? (error as { status?: unknown }).status
|
||||
: null;
|
||||
if (event.provider === "slack") {
|
||||
const response = slackQuestionFormValidationError(error, loaded.payload.fields);
|
||||
if (response) return response;
|
||||
}
|
||||
if (status === 403) {
|
||||
return deny(
|
||||
"chat_modal_resolution_not_authorized",
|
||||
@@ -2003,7 +2020,8 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
);
|
||||
}
|
||||
scheduleMessageProcessing(async () => {
|
||||
await questionResponses.deliver(answered.id);
|
||||
if (answered.kind === "ask_user_questions") await questionResponses.deliver(answered.id);
|
||||
else await processPendingPublications();
|
||||
});
|
||||
return acceptedResponse();
|
||||
}
|
||||
|
||||
+105
-110
@@ -1,3 +1,6 @@
|
||||
import { connectionIntentService } from "../../connection-intents.js";
|
||||
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "../../issue-assignment-wakeup.js";
|
||||
import { chatInteractionPresentation } from "../../chat-interaction-presentation.js";
|
||||
import type { DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../runtime-types.js";
|
||||
|
||||
import { notifyChatPublicationWork } from "../../chat-work-notifications.js";
|
||||
@@ -37,8 +40,8 @@ import { issueThreadInteractionService } from "../../issue-thread-interactions.j
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
import type { ActionEvent } from "chat";
|
||||
import { nativeTelegramConfirmation } from "../../chat-interaction-publications.js";
|
||||
type TelegramConfirmationInput = {
|
||||
import { nativeChatConfirmation, nativeChatCheckbox, nativeChatSuggestions } from "../../chat-interaction-publications.js";
|
||||
type ChatConfirmationInput = {
|
||||
record: EndpointRecord;
|
||||
event: ChatSdkCallbackEvent<ActionEvent>;
|
||||
runtimeContext: RuntimeContext;
|
||||
@@ -49,21 +52,22 @@ type TelegramConfirmationInput = {
|
||||
deny: (known?: { conversationId?: string | null; principalId?: string | null }) => Promise<void>;
|
||||
safelyKnown: { conversationId: string; principalId: string };
|
||||
};
|
||||
type TelegramConfirmationOptions = {
|
||||
type ChatConfirmationOptions = {
|
||||
heartbeat: IssueAssignmentWakeupDeps;
|
||||
requireCurrentExternalActionAuthorization: (tx: DbTransaction, input: { conversationId: string; endpointId: string; expectedUserId: string; principalId: string; runtimeContext: LifecycleRuntimeFence }) => Promise<void>;
|
||||
isExternalActionAuthorizationChange: (error: unknown) => boolean;
|
||||
scheduleMessageProcessing: (task: () => Promise<void>) => void;
|
||||
processPendingPublications: () => Promise<unknown>;
|
||||
confirmationResolutionPersistBarrier?: () => Promise<void>;
|
||||
};
|
||||
export function createTelegramConfirmationActions(db: Db, options: TelegramConfirmationOptions) {
|
||||
export function createChatConfirmationActions(db: Db, options: ChatConfirmationOptions) {
|
||||
const { requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications } = options;
|
||||
async function handleTelegramConfirmation(input: TelegramConfirmationInput) {
|
||||
async function handleChatConfirmation(input: ChatConfirmationInput) {
|
||||
const { record, event, runtimeContext, conversation, principal, issued, interaction, deny, safelyKnown } = input;
|
||||
|
||||
if (
|
||||
event.provider !== "telegram" ||
|
||||
!nativeTelegramConfirmation(interaction)
|
||||
(event.provider !== "telegram" && event.provider !== "slack") ||
|
||||
!(nativeChatConfirmation(interaction) || event.provider === "slack" && (nativeChatCheckbox(interaction) || nativeChatSuggestions(interaction) || interaction.kind === "connection_intent"))
|
||||
) {
|
||||
return deny(safelyKnown);
|
||||
}
|
||||
@@ -134,107 +138,95 @@ export function createTelegramConfirmationActions(db: Db, options: TelegramConfi
|
||||
}
|
||||
|
||||
const decision = tokenPayload.decision;
|
||||
if (interaction.kind === "connection_intent" && decision !== "reject") return deny(safelyKnown);
|
||||
let selectedOptionIds: string[] | undefined;
|
||||
if (interaction.kind === "request_checkbox_confirmation" && decision === "accept") {
|
||||
const mappings = tokenPayload.selectionOptions as Array<{ value: string; optionId: string }> | undefined;
|
||||
const values = event.selectionValues;
|
||||
if (!Array.isArray(mappings) || !values || new Set(values).size !== values.length ||
|
||||
values.some(value => !mappings.some(item => item.value === value))) return deny(safelyKnown);
|
||||
selectedOptionIds = values.map(value => mappings.find(item => item.value === value)!.optionId);
|
||||
const minimum = interaction.payload.minSelected ?? 0;
|
||||
const maximum = interaction.payload.maxSelected ?? interaction.payload.options.length;
|
||||
if (values.length < minimum || values.length > maximum) {
|
||||
await event.event.thread?.postEphemeral(event.event.user, { markdown: `That selection was not saved. ${minimum === maximum ? `Select exactly ${minimum}` : `Select between ${minimum} and ${maximum}`} checks, then save.` }, { fallbackToDM: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
let selectedClientKeys: string[] | undefined;
|
||||
if (interaction.kind === "suggest_tasks" && decision === "accept") {
|
||||
const mappings = tokenPayload.selectionOptions as Array<{ value: string; optionId: string }> | undefined;
|
||||
if (!Array.isArray(mappings) || !mappings.length) return deny(safelyKnown);
|
||||
const values = mappings.length === 1 ? [mappings[0].value] : event.selectionValues;
|
||||
if (!values || new Set(values).size !== values.length || values.some(value => !mappings.some(item => item.value === value))) return deny(safelyKnown);
|
||||
if (!values.length) {
|
||||
await event.event.thread?.postEphemeral(event.event.user, { markdown: "No tasks were created. Select at least one task, then choose Create selected." }, { fallbackToDM: false });
|
||||
return;
|
||||
}
|
||||
selectedClientKeys = values.map(value => mappings.find(item => item.value === value)!.optionId);
|
||||
}
|
||||
const mutationOptions = {
|
||||
beforeResolveInTransaction: async (tx: DbTransaction) => {
|
||||
await requireCurrentExternalActionAuthorization(tx, {
|
||||
conversationId: conversation.id,
|
||||
endpointId: record.endpoint.id,
|
||||
expectedUserId: principal.userId!,
|
||||
principalId: principal.principal.id,
|
||||
runtimeContext,
|
||||
});
|
||||
},
|
||||
afterResolveInTransaction: async (tx: DbTransaction, resolved: import("@paperclipai/shared").IssueThreadInteraction) => {
|
||||
const [owned] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
principalId: principal.principal.id,
|
||||
status: "processing",
|
||||
payload: {
|
||||
...tokenPayload,
|
||||
messageId: event.event.messageId,
|
||||
actionId: event.event.actionId,
|
||||
value: event.event.value ?? null,
|
||||
...(selectedOptionIds ? { selectedOptionIds } : {}),
|
||||
},
|
||||
result: {
|
||||
code: "confirmation_resolution_committed_by_provider",
|
||||
interactionId: resolved.id,
|
||||
interactionStatus: resolved.status,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "issued"),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!owned) {
|
||||
throw new Error(
|
||||
"External confirmation action ownership changed before interaction commit",
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
let createdIssues: Array<{ id: string; assigneeAgentId: string | null; status: string }> = [];
|
||||
try {
|
||||
if (decision === "accept") {
|
||||
await issueThreadInteractionService(db).acceptInteraction(
|
||||
const accepted = await issueThreadInteractionService(db).acceptInteraction(
|
||||
issue,
|
||||
interaction.id,
|
||||
{},
|
||||
{ ...(selectedOptionIds ? { selectedOptionIds } : {}), ...(selectedClientKeys ? { selectedClientKeys } : {}) },
|
||||
{ userId: principal.userId },
|
||||
{
|
||||
beforeResolveInTransaction: async (tx) => {
|
||||
await requireCurrentExternalActionAuthorization(tx, {
|
||||
conversationId: conversation.id,
|
||||
endpointId: record.endpoint.id,
|
||||
expectedUserId: principal.userId!,
|
||||
principalId: principal.principal.id,
|
||||
runtimeContext,
|
||||
});
|
||||
},
|
||||
afterResolveInTransaction: async (tx, resolved) => {
|
||||
const [owned] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
principalId: principal.principal.id,
|
||||
status: "processing",
|
||||
payload: {
|
||||
...tokenPayload,
|
||||
messageId: event.event.messageId,
|
||||
actionId: event.event.actionId,
|
||||
value: null,
|
||||
},
|
||||
result: {
|
||||
code: "confirmation_resolution_committed_by_provider",
|
||||
interactionId: resolved.id,
|
||||
interactionStatus: resolved.status,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "issued"),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!owned) {
|
||||
throw new Error(
|
||||
"External confirmation action ownership changed before interaction commit",
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
mutationOptions,
|
||||
);
|
||||
createdIssues = accepted.createdIssues;
|
||||
} else {
|
||||
await issueThreadInteractionService(db).rejectInteraction(
|
||||
issue,
|
||||
interaction.id,
|
||||
{},
|
||||
{ userId: principal.userId },
|
||||
{
|
||||
beforeResolveInTransaction: async (tx) => {
|
||||
await requireCurrentExternalActionAuthorization(tx, {
|
||||
conversationId: conversation.id,
|
||||
endpointId: record.endpoint.id,
|
||||
expectedUserId: principal.userId!,
|
||||
principalId: principal.principal.id,
|
||||
runtimeContext,
|
||||
});
|
||||
},
|
||||
afterResolveInTransaction: async (tx, resolved) => {
|
||||
const [owned] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
principalId: principal.principal.id,
|
||||
status: "processing",
|
||||
payload: {
|
||||
...tokenPayload,
|
||||
messageId: event.event.messageId,
|
||||
actionId: event.event.actionId,
|
||||
value: null,
|
||||
},
|
||||
result: {
|
||||
code: "confirmation_resolution_committed_by_provider",
|
||||
interactionId: resolved.id,
|
||||
interactionStatus: resolved.status,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.id, action.id),
|
||||
eq(chatActions.status, "issued"),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatActions.id });
|
||||
if (!owned) {
|
||||
throw new Error(
|
||||
"External confirmation action ownership changed before interaction commit",
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (interaction.kind === "connection_intent") {
|
||||
await connectionIntentService(db).decline(interaction.id, principal.userId, undefined, { mutationOptions });
|
||||
} else {
|
||||
await issueThreadInteractionService(db).rejectInteraction(issue, interaction.id, {}, { userId: principal.userId }, mutationOptions);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isExternalActionAuthorizationChange(error)) {
|
||||
@@ -264,6 +256,7 @@ export function createTelegramConfirmationActions(db: Db, options: TelegramConfi
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const createdIssue of createdIssues) await queueIssueAssignmentWakeup({ heartbeat: options.heartbeat, issue: createdIssue, reason: "issue_assigned", mutation: "interaction_accept", contextSource: "issue.interaction.accept", requestedByActorType: "user", requestedByActorId: principal.userId });
|
||||
await options.confirmationResolutionPersistBarrier?.();
|
||||
if (!(await settleTerminalConfirmationAction(action.id))) {
|
||||
throw new Error(
|
||||
@@ -355,7 +348,7 @@ export function createTelegramConfirmationActions(db: Db, options: TelegramConfi
|
||||
!conversation ||
|
||||
!publication ||
|
||||
!endpoint ||
|
||||
endpoint.provider !== "telegram" ||
|
||||
(endpoint.provider !== "telegram" && endpoint.provider !== "slack") ||
|
||||
publication.conversationId !== conversation.id ||
|
||||
publication.payload.interactionId !== token.interactionId
|
||||
) {
|
||||
@@ -379,8 +372,8 @@ export function createTelegramConfirmationActions(db: Db, options: TelegramConfi
|
||||
).find((candidate) => candidate.id === token.interactionId);
|
||||
if (
|
||||
!interaction ||
|
||||
interaction.kind !== "request_confirmation" ||
|
||||
!nativeTelegramConfirmation(interaction)
|
||||
(interaction.kind !== "request_confirmation" && interaction.kind !== "request_checkbox_confirmation" && interaction.kind !== "suggest_tasks" && interaction.kind !== "connection_intent") ||
|
||||
!(nativeChatConfirmation(interaction) || endpoint.provider === "slack" && (nativeChatCheckbox(interaction) || nativeChatSuggestions(interaction) || interaction.kind === "connection_intent"))
|
||||
) {
|
||||
await db
|
||||
.update(chatActions)
|
||||
@@ -480,14 +473,14 @@ export function createTelegramConfirmationActions(db: Db, options: TelegramConfi
|
||||
}
|
||||
const resolvedByUserId = interaction.resolvedByUserId;
|
||||
const shouldWake =
|
||||
Boolean(issue.assigneeAgentId) &&
|
||||
interaction.kind !== "connection_intent" && Boolean(issue.assigneeAgentId) &&
|
||||
issue.status !== "done" &&
|
||||
issue.status !== "cancelled" &&
|
||||
(interaction.continuationPolicy === "wake_assignee" ||
|
||||
(interaction.continuationPolicy === "wake_assignee_on_accept" &&
|
||||
interaction.status === "accepted"));
|
||||
const resolutionLabel =
|
||||
interaction.status === "accepted" ? "Accepted" : "Rejected";
|
||||
interaction.status === "accepted" ? "Accepted" : interaction.kind === "connection_intent" ? "Declined" : "Rejected";
|
||||
const resolvedByThisProviderAction =
|
||||
action.result?.code === "confirmation_resolution_committed_by_provider";
|
||||
return db.transaction(async (tx) => {
|
||||
@@ -556,13 +549,15 @@ export function createTelegramConfirmationActions(db: Db, options: TelegramConfi
|
||||
payload: projectSafeChatPublication({
|
||||
classification: "external",
|
||||
source: "issue_interaction",
|
||||
text: `${resolutionLabel}: ${interaction.payload.prompt}`,
|
||||
text: `${resolutionLabel}: ${chatInteractionPresentation(interaction).title}`,
|
||||
interaction: {
|
||||
id: interaction.id,
|
||||
card: {
|
||||
kind: "confirmation",
|
||||
title: interaction.payload.prompt,
|
||||
body: resolutionLabel,
|
||||
title: chatInteractionPresentation(interaction).title,
|
||||
body: interaction.kind === "request_checkbox_confirmation" && interaction.status === "accepted"
|
||||
? `Saved checks: ${interaction.payload.options.filter(option => interaction.result?.selectedOptionIds?.includes(option.id)).map(option => option.label).join("; ") || "none selected"}`
|
||||
: resolutionLabel,
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
@@ -652,5 +647,5 @@ export function createTelegramConfirmationActions(db: Db, options: TelegramConfi
|
||||
}
|
||||
return settled;
|
||||
}
|
||||
return { settleTerminalConfirmationAction, reconcileTerminalConfirmationActions, handleTelegramConfirmation };
|
||||
return { settleTerminalConfirmationAction, reconcileTerminalConfirmationActions, handleChatConfirmation };
|
||||
}
|
||||
@@ -469,10 +469,6 @@ export function createChatPublicationReplacement(db: Db, options: ChatPublicatio
|
||||
eq(issueThreadInteractions.id, payload.interactionId),
|
||||
eq(issueThreadInteractions.companyId, publication.companyId),
|
||||
eq(issueThreadInteractions.issueId, publication.issueId),
|
||||
inArray(issueThreadInteractions.kind, [
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
]),
|
||||
),
|
||||
);
|
||||
runId = interaction?.runId ?? null;
|
||||
@@ -643,10 +639,6 @@ export function createChatPublicationReplacement(db: Db, options: ChatPublicatio
|
||||
eq(issueThreadInteractions.id, payload.interactionId),
|
||||
eq(issueThreadInteractions.companyId, publication.companyId),
|
||||
eq(issueThreadInteractions.issueId, publication.issueId),
|
||||
inArray(issueThreadInteractions.kind, [
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
]),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
@@ -711,10 +703,6 @@ export function createChatPublicationReplacement(db: Db, options: ChatPublicatio
|
||||
eq(issueThreadInteractions.companyId, publication.companyId),
|
||||
eq(issueThreadInteractions.issueId, publication.issueId),
|
||||
eq(issueThreadInteractions.sourceRunId, sourceRunId),
|
||||
inArray(issueThreadInteractions.kind, [
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
]),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.map((row) => row.id));
|
||||
@@ -940,7 +928,6 @@ export function createChatPublicationReplacement(db: Db, options: ChatPublicatio
|
||||
where consuming_interaction.company_id = ${publication.companyId}::uuid
|
||||
and consuming_interaction.issue_id = ${publication.issueId}::uuid
|
||||
and consuming_interaction.source_run_id = ${lane.runId}::uuid
|
||||
and consuming_interaction.kind in ('ask_user_questions', 'request_confirmation')
|
||||
and ${chatPublications.payload}->>'interactionId' = consuming_interaction.id::text
|
||||
and ${chatPublications.idempotencyKey} = 'interaction:' || consuming_interaction.id::text || ':' || ${publication.endpointId}
|
||||
)`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { slackPublicationCard } from "../slack/cards.js";
|
||||
import type { PublicationTransportInput } from "./types.js";
|
||||
import { createPhotonPublicationDelivery } from "../imessage-photon/publications.js";
|
||||
import { createOutboundAttachments } from "../outbound-attachments.js";
|
||||
@@ -122,6 +123,7 @@ function safeCardForPublication(
|
||||
payload: SafeChatPublicationPayload,
|
||||
provider: ChatProvider,
|
||||
) {
|
||||
if (provider === "slack") return slackPublicationCard(payload);
|
||||
if (!payload.card) return null;
|
||||
const children = [];
|
||||
if (payload.card.body) children.push(CardText(payload.card.body));
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import { SlackAdapter } from "@chat-adapter/slack";
|
||||
import type { LinkPreview } from "chat";
|
||||
import type { AdapterPostableMessage, LinkPreview, RawMessage } from "chat";
|
||||
import { slackSelectionMessage } from "./cards.js";
|
||||
|
||||
export class PaperclipSlackAdapter extends SlackAdapter {
|
||||
override async postMessage(threadId: string, message: AdapterPostableMessage): Promise<RawMessage> {
|
||||
const payload = slackSelectionMessage(message);
|
||||
if (!payload) return super.postMessage(threadId, message);
|
||||
const { channel, threadTs } = this.decodeThreadId(threadId);
|
||||
try {
|
||||
const result = await this._client.chat.postMessage(await this.withToken({ channel, thread_ts: threadTs || undefined, ...payload, unfurl_links: false, unfurl_media: false }));
|
||||
if (!result.ts) throw new Error("Slack did not return a message receipt");
|
||||
return { id: result.ts, threadId, raw: result };
|
||||
} catch (error) { this.handleSlackError(error); }
|
||||
}
|
||||
|
||||
override async editMessage(threadId: string, messageId: string, message: AdapterPostableMessage): Promise<RawMessage> {
|
||||
const payload = slackSelectionMessage(message);
|
||||
if (!payload) return super.editMessage(threadId, messageId, message);
|
||||
const { channel } = this.decodeThreadId(threadId);
|
||||
try {
|
||||
const result = await this._client.chat.update(await this.withToken({ channel, ts: messageId, ...payload }));
|
||||
if (!result.ts) throw new Error("Slack did not return an edit receipt");
|
||||
return { id: result.ts, threadId, raw: result };
|
||||
} catch (error) { this.handleSlackError(error); }
|
||||
}
|
||||
|
||||
// The SDK polls for an optional Slack unfurl for two seconds before it
|
||||
// delivers a message containing a URL. Paperclip accepts the message text,
|
||||
// links and files directly; preview metadata is not part of task admission.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cardToBlockKit } from "@chat-adapter/slack";
|
||||
import { projectSafeChatPublication } from "../../chat-publication-projection.js";
|
||||
import { isPassiveSlackAction, normalizeSlackSelectionAction, slackPublicationCard, slackSelectionMessage, slackSelectionValues } from "./cards.js";
|
||||
|
||||
describe("Slack interaction card wire payloads", () => {
|
||||
it.each([120, 32000, 100000])("renders a %i-character request within Slack limits", length => {
|
||||
const payload = projectSafeChatPublication({
|
||||
classification: "external", source: "issue_interaction", text: "Read this request in Paperclip",
|
||||
interaction: { id: "interaction-1", card: {
|
||||
kind: "question", title: "😀".repeat(160), body: "Q".repeat(length),
|
||||
actions: [{ type: "link", label: "😀".repeat(80), url: "https://paperclip.example/issues/task-1" }],
|
||||
} },
|
||||
});
|
||||
const blocks = cardToBlockKit(slackPublicationCard(payload)!) as Array<{
|
||||
type: string;
|
||||
text?: { text: string };
|
||||
elements?: Array<{ text: { text: string } }>;
|
||||
}>;
|
||||
expect(blocks.length).toBeLessThanOrEqual(50);
|
||||
expect(blocks[0]).toMatchObject({ type: "header", text: { text: "😀".repeat(150) } });
|
||||
for (const block of blocks) {
|
||||
if (block.type === "section") expect(Array.from(block.text!.text).length).toBeLessThanOrEqual(3000);
|
||||
if (block.type === "actions") expect(block.elements![0]).toMatchObject({ text: { text: "😀".repeat(75) } });
|
||||
}
|
||||
const wire = JSON.stringify(blocks);
|
||||
if (length > 32000) expect(wire).toContain("too long for Slack");
|
||||
else expect(blocks.filter(block => block.type === "section").map(block => block.text!.text).join("")).toBe("Q".repeat(length));
|
||||
expect(wire).toContain("https://paperclip.example/issues/task-1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Slack inline selection", () => {
|
||||
it("renders searchable choices with opaque tokens and no repeated prompt", () => {
|
||||
const payload = projectSafeChatPublication({ classification: "external", source: "issue_interaction", text: "Choose a fixture", interaction: {
|
||||
id: "interaction-1", card: { kind: "question", title: "Which fixture?", body: "Which fixture?\n\nChoose one.", selection: {
|
||||
actionId: "pcs:interaction-1", label: "Choose a fixture", options: Array.from({ length: 13 }, (_, i) => ({ actionId: `pcq:option-${i}`, label: `Fixture ${i + 1}` })),
|
||||
} },
|
||||
} });
|
||||
const wire = slackSelectionMessage({ card: slackPublicationCard(payload)! });
|
||||
expect(wire?.blocks).toContainEqual(expect.objectContaining({ type: "actions", elements: [expect.objectContaining({ type: "static_select", options: expect.arrayContaining([expect.objectContaining({ value: "pcq:option-12|interaction-1", text: { type: "plain_text", text: "Fixture 13" } })]) })] }));
|
||||
expect(JSON.stringify(wire?.blocks).match(/Which fixture\?/g)).toHaveLength(1);
|
||||
const event = { actionId: "pcs:interaction-1", value: "pcq:option-12|interaction-1" } as Parameters<typeof normalizeSlackSelectionAction>[0];
|
||||
expect(normalizeSlackSelectionAction(event)).toMatchObject({ actionId: "pcq:option-12", value: "interaction-1" });
|
||||
const forged = { ...event, value: "pcq:option-12|other-interaction" };
|
||||
expect(normalizeSlackSelectionAction(forged)).toBe(forged);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("keeps multiple selections editable until explicit submit", () => {
|
||||
const payload = projectSafeChatPublication({ classification: "external", source: "issue_interaction", text: "Choose coverage", interaction: { id: "interaction-1", card: { kind: "question", title: "Coverage?", selection: { actionId: "pcq:submit", label: "Choose coverage", multiple: true, submitLabel: "Submit answer", options: [{ actionId: "pcq:legacy", label: "Legacy" }, { actionId: "pcq:native", label: "Native" }] } } } });
|
||||
const wire = slackSelectionMessage({ card: slackPublicationCard(payload)! });
|
||||
expect(wire?.blocks).toContainEqual({ type: "actions", elements: [{ type: "checkboxes", action_id: "pcsv:pcq:submit", options: [{ text: { type: "plain_text", text: "Legacy" }, value: "pcq:legacy" }, { text: { type: "plain_text", text: "Native" }, value: "pcq:native" }] }] });
|
||||
expect(wire?.blocks.at(-1)).toMatchObject({ elements: [{ type: "button", action_id: "pcq:submit", value: "interaction-1" }] });
|
||||
const event = { actionId: "pcq:submit", raw: { state: { values: { block: { "pcsv:pcq:submit": { selected_options: [{ value: "pcq:legacy" }, { value: "pcq:native" }] } } } } } } as Parameters<typeof slackSelectionValues>[0];
|
||||
expect(slackSelectionValues(event)).toEqual(["pcq:legacy", "pcq:native"]);
|
||||
expect(slackSelectionValues({ ...event, actionId: "pcq:unrelated" })).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
describe("untrusted Slack selection state", () => {
|
||||
const event = (raw: unknown) => ({ actionId: "pcq:submit", raw } as Parameters<typeof slackSelectionValues>[0]);
|
||||
it.each([null, [], { state: null }, { state: { values: { block: null } } },
|
||||
{ state: { values: { block: { "pcsv:pcq:submit": { selected_options: [null] } } } } },
|
||||
{ state: { values: { block: { "pcsv:pcq:submit": { selected_options: [{ value: "x" }, { value: "x" }] } } } } },
|
||||
])("rejects malformed or duplicate selections without throwing: %j", raw => {
|
||||
expect(slackSelectionValues(event(raw))).toBeUndefined();
|
||||
});
|
||||
it("does not choose between ambiguous state blocks", () => {
|
||||
const field = { "pcsv:pcq:submit": { selected_options: [{ value: "x" }] } };
|
||||
expect(slackSelectionValues(event({ state: { values: { one: field, two: field } } }))).toBeUndefined();
|
||||
});
|
||||
it("preserves an explicit empty selection", () => {
|
||||
expect(slackSelectionValues(event({ state: { values: { one: { "pcsv:pcq:submit": { selected_options: [] } } } } }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("acknowledges rendered link buttons without treating navigation as an answer", () => {
|
||||
const payload = projectSafeChatPublication({ classification: "external", source: "issue_interaction", text: "Review GitHub", interaction: { id: "interaction-1", card: { kind: "question", title: "Connect GitHub", actions: [{ type: "link", label: "Review connection", url: "https://paperclip.example/issues/task-1" }, { type: "callback", label: "Not now", actionId: "pcq:decline" }] } } });
|
||||
const blocks = cardToBlockKit(slackPublicationCard(payload)!);
|
||||
const actions = blocks.flatMap(block => block.type === "actions" ? block.elements : []);
|
||||
const link = actions.find((action): action is { url: string; action_id: string } =>
|
||||
typeof action === "object" && action !== null && "url" in action && "action_id" in action
|
||||
&& typeof action.url === "string" && typeof action.action_id === "string");
|
||||
expect(link).toMatchObject({ type: "button", url: "https://paperclip.example/issues/task-1" });
|
||||
expect(isPassiveSlackAction({ actionId: link!.action_id! })).toBe(true);
|
||||
expect(isPassiveSlackAction({ actionId: "pcsv:pcq:submit" })).toBe(true);
|
||||
expect(isPassiveSlackAction({ actionId: "pcq:decline" })).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { cardToBlockKit } from "@chat-adapter/slack";
|
||||
import type { AdapterPostableMessage, ActionEvent } from "chat";
|
||||
import type { SafeChatPublicationPayload } from "@paperclipai/shared";
|
||||
import { Actions, Button, Card, CardText, LinkButton, type CardElement } from "chat";
|
||||
import { isChatQuestionFormOpenActionId } from "../../chat-question-forms.js";
|
||||
|
||||
const MAX_CARD_BODY_LENGTH = 32_000;
|
||||
|
||||
export function slackInteractionRequiresBoard(body: string): boolean {
|
||||
return Array.from(body).length > MAX_CARD_BODY_LENGTH;
|
||||
}
|
||||
|
||||
export function slackPublicationCard(payload: SafeChatPublicationPayload): CardElement | null {
|
||||
const card = payload.card;
|
||||
if (!card) return null;
|
||||
const children: CardElement["children"] = [];
|
||||
const body = Array.from((card.body ?? "").split("\n\n").filter(paragraph => paragraph.trim() !== card.title.trim()).join("\n\n"));
|
||||
const visibleBody = body.slice(0, MAX_CARD_BODY_LENGTH);
|
||||
for (let offset = 0; offset < visibleBody.length; offset += 2000) {
|
||||
children.push(CardText(visibleBody.slice(offset, offset + 2000).join("")));
|
||||
}
|
||||
if (body.length > MAX_CARD_BODY_LENGTH) children.push(CardText("This request is too long for Slack. Open it in Paperclip to read the complete request and respond."));
|
||||
const actions = (card.actions ?? []).map(action => {
|
||||
const label = Array.from(action.label).slice(0, 75).join("");
|
||||
return action.type === "callback"
|
||||
? Button({ id: action.actionId, label, style: action.style ?? "default", value: payload.interactionId, actionType: isChatQuestionFormOpenActionId(action.actionId) ? "modal" : undefined })
|
||||
: LinkButton({ label, url: action.url });
|
||||
});
|
||||
if (actions.length) children.push(Actions(actions));
|
||||
const rendered = Card({ title: Array.from(card.title).slice(0, 150).join(""), children });
|
||||
if (!card.selection) return rendered;
|
||||
return Object.assign(rendered, { paperclipSelection: card.selection, paperclipInteractionId: payload.interactionId });
|
||||
}
|
||||
|
||||
/** Slack-only wire rendering; the durable publication contains provider-neutral choices. */
|
||||
export function slackSelectionMessage(message: AdapterPostableMessage) {
|
||||
if (typeof message !== "object" || !("card" in message)) return null;
|
||||
const card = message.card as CardElement & {
|
||||
paperclipSelection?: NonNullable<SafeChatPublicationPayload["card"]>["selection"];
|
||||
paperclipInteractionId?: string;
|
||||
};
|
||||
const selection = card.paperclipSelection;
|
||||
if (!selection || !card.paperclipInteractionId) return null;
|
||||
const options = selection.options.map(option => ({
|
||||
text: { type: "plain_text" as const, text: option.label },
|
||||
value: selection.multiple ? option.actionId : `${option.actionId}|${card.paperclipInteractionId}`,
|
||||
...(option.description ? { description: { type: "plain_text" as const, text: option.description } } : {}),
|
||||
}));
|
||||
const blocks = cardToBlockKit(card);
|
||||
const initial_options = options.filter((_, index) => selection.options[index].selected);
|
||||
const initial = initial_options.length ? { initial_options } : {};
|
||||
const footer = blocks.filter(block => block.type === "actions");
|
||||
for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].type === "actions") blocks.splice(i, 1);
|
||||
if (selection.multiple) {
|
||||
blocks.push({ type: "actions", elements: [options.length <= 10
|
||||
? { type: "checkboxes", action_id: `pcsv:${selection.actionId}`, options, ...initial }
|
||||
: { type: "multi_static_select", action_id: `pcsv:${selection.actionId}`, placeholder: { type: "plain_text", text: selection.label }, options, ...initial }] });
|
||||
blocks.push({ type: "actions", elements: [{ type: "button", action_id: selection.actionId, text: { type: "plain_text", text: selection.submitLabel ?? "Submit" }, value: card.paperclipInteractionId, style: "primary" }, ...footer.flatMap(block => block.type === "actions" ? block.elements : [])] });
|
||||
} else {
|
||||
blocks.push({ type: "actions", elements: [{ type: "static_select", action_id: selection.actionId, placeholder: { type: "plain_text", text: selection.label }, options, ...(initial_options[0] ? { initial_option: initial_options[0] } : {}) }] });
|
||||
}
|
||||
if (!selection.multiple) blocks.push(...footer);
|
||||
return { text: message.fallbackText ?? card.title ?? "Choose an answer", blocks };
|
||||
}
|
||||
|
||||
/** URL buttons navigate in Slack; checkbox edits wait for an explicit submit. */
|
||||
export function isPassiveSlackAction(event: Pick<ActionEvent, "actionId">): boolean {
|
||||
return event.actionId.startsWith("link-") || event.actionId.startsWith("pcsv:");
|
||||
}
|
||||
|
||||
/** Convert the selected opaque token to the existing authorized button-answer path. */
|
||||
export function normalizeSlackSelectionAction(event: ActionEvent): ActionEvent {
|
||||
if (!event.actionId.startsWith("pcs:")) return event;
|
||||
const [actionId, interactionId, extra] = (event.value ?? "").split("|");
|
||||
if (extra !== undefined || !actionId?.startsWith("pcq:") || event.actionId !== `pcs:${interactionId}`) return event;
|
||||
return { ...event, actionId, value: interactionId };
|
||||
}
|
||||
|
||||
|
||||
/** Selection changes stay client-side; only the explicit submit resolves work. */
|
||||
export function slackSelectionValues(event: ActionEvent): string[] | undefined {
|
||||
const record = (value: unknown): Record<string, unknown> | null =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : null;
|
||||
const values = record(record(record(event.raw)?.state)?.values);
|
||||
if (!values) return undefined;
|
||||
const fieldId = `pcsv:${event.actionId}`;
|
||||
const matches = Object.values(values).flatMap(block => {
|
||||
const field = record(record(block)?.[fieldId]);
|
||||
return field ? [field] : [];
|
||||
});
|
||||
if (matches.length !== 1) return undefined;
|
||||
const selected = matches[0].selected_options;
|
||||
if (!Array.isArray(selected) || selected.length > 100) return undefined;
|
||||
const tokens = selected.map(option => record(option)?.value);
|
||||
if (tokens.some(value => typeof value !== "string") || new Set(tokens).size !== tokens.length) return undefined;
|
||||
return tokens as string[];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { forbidden, unprocessable } from "../../../errors.js";
|
||||
import { slackQuestionFormValidationError } from "./question-forms.js";
|
||||
|
||||
const fields = ["first", "second"].map(questionId => ({
|
||||
fieldId: `field-${questionId}`, questionId, kind: "text" as const,
|
||||
maxLength: 3000, minLength: 0, inputType: "text" as const, required: true,
|
||||
}));
|
||||
|
||||
describe("Slack canonical form validation feedback", () => {
|
||||
it("attaches a format error to its question without reflecting internal details", () => {
|
||||
expect(slackQuestionFormValidationError(unprocessable(
|
||||
"Question second does not match the required format", { code: "invalid_question_response" },
|
||||
), fields)).toEqual({ action: "errors", errors: { "field-second": "Use the required format shown above." } });
|
||||
});
|
||||
|
||||
it("uses safe feedback when canonical validation has no specific field", () => {
|
||||
expect(slackQuestionFormValidationError(unprocessable(
|
||||
"private validation detail", { code: "invalid_question_response" },
|
||||
), fields)).toEqual({ action: "errors", errors: { "field-first": "The answer could not be validated. Please try again." } });
|
||||
});
|
||||
|
||||
it("leaves authorization and unrelated failures to shared handling", () => {
|
||||
for (const error of [null, forbidden(), unprocessable("Other failure"), new Error("transport failure")]) {
|
||||
expect(slackQuestionFormValidationError(error, fields)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ModalResponse } from "chat";
|
||||
import type { ChatQuestionFormSubmitTokenPayload } from "../../chat-question-forms.js";
|
||||
|
||||
/** Keep Slack's modal open when canonical answer validation rejects a field. */
|
||||
export function slackQuestionFormValidationError(
|
||||
error: unknown,
|
||||
fields: ChatQuestionFormSubmitTokenPayload["fields"],
|
||||
): ModalResponse | null {
|
||||
if (!error || typeof error !== "object") return null;
|
||||
const failure = error as { status?: unknown; details?: { code?: unknown } };
|
||||
if (failure.status !== 422 || failure.details?.code !== "invalid_question_response") return null;
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const field = fields.find(item =>
|
||||
message === `Question ${item.questionId} does not match the required format`) ?? fields[0];
|
||||
if (!field) return null;
|
||||
return {
|
||||
action: "errors",
|
||||
errors: {
|
||||
[field.fieldId]: message.includes("does not match the required format")
|
||||
? "Use the required format shown above."
|
||||
: "The answer could not be validated. Please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -47,12 +47,9 @@ export async function hasChatRunOwnedProviderInteraction(
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
eq(issueThreadInteractions.issueId, input.issueId),
|
||||
eq(issueThreadInteractions.sourceRunId, input.runId),
|
||||
inArray(issueThreadInteractions.kind, [
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
]),
|
||||
or(
|
||||
and(
|
||||
inArray(issueThreadInteractions.kind, ["ask_user_questions", "request_confirmation"]),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
exists(
|
||||
db
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents, agentWakeupRequests, authUsers, chatConversations, chatEndpoints, chatMessageLinks,
|
||||
chatPublications, connectionIntentDeliveries, companies, companyMemberships, closeRegisteredClients, createDb, heartbeatRuns,
|
||||
issueComments, issueThreadInteractions, issues, toolApplications, toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
||||
import { resolveChatRunPresentationAuthorizationReason, enqueueChatRunMilestones } from "./chat-run-publications.js";
|
||||
import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON, isChatOriginPublicationCandidate, isExternalChatPresentationContext, resolveHeartbeatRunResponse } from "./heartbeat-run-summary.js";
|
||||
import { resolveChatOriginPublicationBindings } from "./issues.js";
|
||||
|
||||
describe("chat interaction continuation delivery", () => {
|
||||
const externalUrl = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
if (!externalUrl) temporary = await startEmbeddedPostgresTestDatabase("chat-interaction-delivery-");
|
||||
db = createDb(externalUrl ?? temporary!.connectionString);
|
||||
await db.insert(authUsers).values({ id: "requester", name: "Requester", email: "requester@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
||||
}, 30_000);
|
||||
afterAll(async () => { await temporary?.cleanup(); if (externalUrl) await closeRegisteredClients(externalUrl); });
|
||||
async function fixture(kind = "request_checkbox_confirmation", status = "accepted") {
|
||||
const [company] = await db.insert(companies).values({ name: "Continuation", issuePrefix: randomUUID().slice(0, 8) }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: "requester", status: "active", membershipRole: "owner" });
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", adapterType: "paperclip_runner" }).returning();
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Repository QA", status: "in_progress", assigneeAgentId: agent.id, responsibleUserId: "requester" }).returning();
|
||||
const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: "Slack", type: "chat" }).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, name: "Slack", uid: randomUUID(), connectionPurpose: "channel", transport: "chat_sdk" }).returning();
|
||||
const [endpoint] = await db.insert(chatEndpoints).values({ companyId: company.id, connectionId: connection.id, provider: "slack", publicId: randomUUID(), assignedAgentId: agent.id, status: "active" }).returning();
|
||||
const [conversation] = await db.insert(chatConversations).values({ companyId: company.id, endpointId: endpoint.id, issueId: issue.id, externalConversationId: "test-channel", externalThreadId: randomUUID(), externalLabel: "QA" }).returning();
|
||||
const [comment] = await db.insert(issueComments).values({ companyId: company.id, issueId: issue.id, authorType: "user", authorUserId: "requester", body: "QA the runner migration" }).returning();
|
||||
await db.insert(chatMessageLinks).values({ companyId: company.id, endpointId: endpoint.id, conversationId: conversation.id, commentId: comment.id, providerMessageId: randomUUID(), direction: "inbound" });
|
||||
const [source] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
|
||||
status: "succeeded", responsibleUserId: "requester", contextSnapshot: {
|
||||
source: "chat:slack", issueId: issue.id, endpointId: endpoint.id, wakeCommentId: comment.id,
|
||||
} }).returning();
|
||||
const [interaction] = await db.insert(issueThreadInteractions).values({ companyId: company.id, issueId: issue.id,
|
||||
kind, status, sourceRunId: source.id, payload: { version: 1 } as never,
|
||||
result: { version: 1, outcome: "resolved", complete: status === "answered", items: [{ id: "one", verdict: "approve" }] } as never,
|
||||
}).returning();
|
||||
const isConnection = kind === "connection_intent";
|
||||
if (isConnection) await db.insert(connectionIntentDeliveries).values({ companyId: company.id, interactionId: interaction.id });
|
||||
const isVerdicts = kind === "request_item_verdicts";
|
||||
const payload = { issueId: issue.id, interactionId: interaction.id, interactionKind: kind,
|
||||
interactionStatus: status, ...(isConnection ? {} : { sourceRunId: source.id }), mutation: "interaction",
|
||||
...(isVerdicts ? { newlyResolvedItemIds: ["one"] } : {}),
|
||||
};
|
||||
const sourceName = isVerdicts ? "verdicts" : status === "rejected" ? "reject" : status === "cancelled" ? "cancel" : "accept";
|
||||
const [successor] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
|
||||
status: "running", responsibleUserId: "requester", contextSnapshot: { ...payload, source: isConnection ? "connection_intent.resolved" : `issue.interaction.${sourceName}` },
|
||||
}).returning();
|
||||
const [wake] = await db.insert(agentWakeupRequests).values({ companyId: company.id, agentId: agent.id,
|
||||
source: "automation", reason: "issue_commented", status: "claimed", runId: successor.id, payload,
|
||||
idempotencyKey: isConnection ? `connection-intent:${interaction.id}:${status}` : isVerdicts ? `request_item_verdicts:${issue.id}:${interaction.id}:123` : `interaction:${interaction.id}:${status}`,
|
||||
}).returning();
|
||||
await db.insert(chatPublications).values({ companyId: company.id, issueId: issue.id, endpointId: endpoint.id,
|
||||
conversationId: conversation.id, state: "published", idempotencyKey: `interaction:${interaction.id}:${endpoint.id}`,
|
||||
payload: { interactionId: interaction.id, text: "Please review the QA result" },
|
||||
});
|
||||
const bindings = () => resolveChatOriginPublicationBindings(db, company.id, issue.id, successor.id);
|
||||
return { company, agent, issue, endpoint, conversation, source, successor, wake, interaction, bindings };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["request_confirmation", "accepted"], ["request_confirmation", "rejected"],
|
||||
["request_checkbox_confirmation", "accepted"], ["request_checkbox_confirmation", "rejected"],
|
||||
["suggest_tasks", "accepted"], ["suggest_tasks", "rejected"],
|
||||
["connection_intent", "accepted"], ["connection_intent", "rejected"],
|
||||
["request_item_verdicts", "answered"], ["request_item_verdicts", "pending"],
|
||||
])("delivers the final response after %s becomes %s", async (kind, status) => {
|
||||
const f = await fixture(kind, status);
|
||||
expect(await f.bindings()).toEqual([{ companyId: f.company.id, endpointId: f.endpoint.id, conversationId: f.conversation.id }]);
|
||||
const authorization = isChatOriginPublicationCandidate(f.successor.contextSnapshot)
|
||||
? await resolveChatRunPresentationAuthorizationReason(db, { companyId: f.company.id, issueId: f.issue.id, runId: f.successor.id }) : null;
|
||||
expect(authorization).toBe(CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON);
|
||||
const resolved = resolveHeartbeatRunResponse({ resultJson: {},
|
||||
existingComment: { id: "bookkeeping", body: "Saved the review." },
|
||||
finalAgentMessage: { text: "Verified result delivered.", channel: "final", sourceEventId: "final" },
|
||||
preferFinalResponseOverExistingComment: isExternalChatPresentationContext(f.successor.contextSnapshot, authorization === CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON),
|
||||
});
|
||||
expect(resolved.decision).toMatchObject({ chosenSource: "final_agent_message", commentAction: "create" });
|
||||
await enqueueChatRunMilestones(db);
|
||||
const published = await db.select().from(chatPublications).where(and(eq(chatPublications.issueId, f.issue.id), eq(chatPublications.idempotencyKey, `run:${f.successor.id}:working:${f.endpoint.id}`)));
|
||||
expect(published).toHaveLength(1);
|
||||
expect(published[0].conversationId).toBe(f.conversation.id);
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.successor.id));
|
||||
});
|
||||
|
||||
it.each(["wake", "source", "agent", "conversation", "internal"])("withholds replies with invalid %s authority", async fault => {
|
||||
const f = await fixture();
|
||||
if (fault === "wake") await db.update(agentWakeupRequests).set({ runId: null }).where(eq(agentWakeupRequests.id, f.wake.id));
|
||||
if (fault === "source") await db.update(issueThreadInteractions).set({ sourceRunId: null }).where(eq(issueThreadInteractions.id, f.interaction.id));
|
||||
if (fault === "agent") {
|
||||
const [other] = await db.insert(agents).values({ companyId: f.company.id, name: "Other", adapterType: "process" }).returning();
|
||||
await db.update(agentWakeupRequests).set({ agentId: other.id }).where(eq(agentWakeupRequests.id, f.wake.id));
|
||||
}
|
||||
if (fault === "conversation") {
|
||||
const [other] = await db.insert(issues).values({ companyId: f.company.id, title: "Unrelated task" }).returning();
|
||||
await db.update(chatConversations).set({ issueId: other.id }).where(eq(chatConversations.id, f.conversation.id));
|
||||
}
|
||||
if (fault === "internal") await db.update(heartbeatRuns).set({ contextSnapshot: { ...f.successor.contextSnapshot, source: "issue.assigned" } }).where(eq(heartbeatRuns.id, f.successor.id));
|
||||
expect(await f.bindings()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["key", "unsaved", "payload", "skipped"])("rejects an item-review wake with %s proof", async fault => {
|
||||
const f = await fixture("request_item_verdicts", "pending");
|
||||
if (fault === "key") await db.update(agentWakeupRequests).set({ idempotencyKey: "forged" }).where(eq(agentWakeupRequests.id, f.wake.id));
|
||||
if (fault === "unsaved") await db.update(issueThreadInteractions).set({ result: null }).where(eq(issueThreadInteractions.id, f.interaction.id));
|
||||
if (fault === "payload") await db.update(agentWakeupRequests).set({ payload: { ...f.wake.payload, newlyResolvedItemIds: ["not-saved"] } }).where(eq(agentWakeupRequests.id, f.wake.id));
|
||||
if (fault === "skipped") await db.update(agentWakeupRequests).set({ status: "skipped" }).where(eq(agentWakeupRequests.id, f.wake.id));
|
||||
expect(await f.bindings()).toEqual([]);
|
||||
});
|
||||
it.each(["accepted", "rejected"])("delivers an automation-origin connection %s through its published Slack prompt", async status => {
|
||||
const f = await fixture("connection_intent", status);
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { source: "automation", issueId: f.issue.id } }).where(eq(heartbeatRuns.id, f.source.id));
|
||||
expect(await f.bindings()).toEqual([{ companyId: f.company.id, endpointId: f.endpoint.id, conversationId: f.conversation.id }]);
|
||||
expect(await resolveChatRunPresentationAuthorizationReason(db, { companyId: f.company.id, issueId: f.issue.id, runId: f.successor.id })).toBe(CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON);
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.successor.id));
|
||||
});
|
||||
|
||||
it.each(["publication", "receipt"])("withholds automation-origin connection replies without the %s", async missing => {
|
||||
const f = await fixture("connection_intent", "rejected");
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { source: "automation", issueId: f.issue.id } }).where(eq(heartbeatRuns.id, f.source.id));
|
||||
if (missing === "publication") await db.update(chatPublications).set({ state: "pending" }).where(eq(chatPublications.issueId, f.issue.id));
|
||||
if (missing === "receipt") await db.delete(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, f.interaction.id));
|
||||
expect(await f.bindings()).toEqual([]);
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.successor.id));
|
||||
});
|
||||
|
||||
it.each(["delivery", "wake", "key", "pending", "source"])("rejects connection continuation with missing %s proof", async fault => {
|
||||
const f = await fixture("connection_intent", "rejected");
|
||||
if (fault === "delivery") await db.delete(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, f.interaction.id));
|
||||
if (fault === "wake") await db.update(agentWakeupRequests).set({ runId: null }).where(eq(agentWakeupRequests.id, f.wake.id));
|
||||
if (fault === "key") await db.update(agentWakeupRequests).set({ idempotencyKey: "forged" }).where(eq(agentWakeupRequests.id, f.wake.id));
|
||||
if (fault === "pending") await db.update(issueThreadInteractions).set({ status: "pending" }).where(eq(issueThreadInteractions.id, f.interaction.id));
|
||||
if (fault === "source") await db.update(issueThreadInteractions).set({ sourceRunId: null }).where(eq(issueThreadInteractions.id, f.interaction.id));
|
||||
expect(await f.bindings()).toEqual([]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { agentWakeupRequests, connectionIntentDeliveries, issueThreadInteractions, type Db } from "@paperclipai/db";
|
||||
import { and, eq, ne, or, inArray, sql } from "drizzle-orm";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
// Item reviews may resume after a partial decision. Their existing wake key is
|
||||
// windowed, so an interaction/status key cannot prove this continuation. Match
|
||||
// the server-owned wake to saved decisions before following its source run.
|
||||
export async function resolveItemVerdictContinuationWake(db: Db, input: {
|
||||
companyId: string; issueId: string; interactionId: string; sourceRunId: string;
|
||||
runId: string; agentId: string; result: unknown;
|
||||
}): Promise<{ id: string } | null> {
|
||||
const result = record(input.result);
|
||||
if (result.outcome !== "resolved" || !Array.isArray(result.items)) return null;
|
||||
const savedIds = new Set(result.items.map(item => record(item).id));
|
||||
const prefix = `request_item_verdicts:${input.issueId}:${input.interactionId}:`;
|
||||
const wakes = await db.select({ id: agentWakeupRequests.id,
|
||||
key: agentWakeupRequests.idempotencyKey, payload: agentWakeupRequests.payload,
|
||||
}).from(agentWakeupRequests).where(and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId),
|
||||
eq(agentWakeupRequests.agentId, input.agentId),
|
||||
eq(agentWakeupRequests.runId, input.runId),
|
||||
eq(agentWakeupRequests.source, "automation"),
|
||||
eq(agentWakeupRequests.reason, "issue_commented"),
|
||||
ne(agentWakeupRequests.status, "skipped"),
|
||||
));
|
||||
return wakes.find(wake => {
|
||||
const payload = record(wake.payload);
|
||||
const ids = payload.newlyResolvedItemIds;
|
||||
return wake.key?.startsWith(prefix) && /^\d+$/.test(wake.key.slice(prefix.length)) &&
|
||||
payload.issueId === input.issueId && payload.interactionId === input.interactionId &&
|
||||
payload.sourceRunId === input.sourceRunId && payload.interactionKind === "request_item_verdicts" &&
|
||||
payload.mutation === "interaction" && Array.isArray(ids) && ids.length > 0 &&
|
||||
ids.every(id => typeof id === "string" && savedIds.has(id));
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
export async function resolveConnectionIntentContinuationSource(db: Db, input: {
|
||||
companyId: string; issueId: string; interactionId: string; runId: string; agentId: string;
|
||||
}): Promise<string | null> {
|
||||
const [receipt] = await db.select({ sourceRunId: issueThreadInteractions.sourceRunId })
|
||||
.from(issueThreadInteractions)
|
||||
.innerJoin(connectionIntentDeliveries, and(
|
||||
eq(connectionIntentDeliveries.companyId, issueThreadInteractions.companyId),
|
||||
eq(connectionIntentDeliveries.interactionId, issueThreadInteractions.id),
|
||||
))
|
||||
.innerJoin(agentWakeupRequests, and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId),
|
||||
eq(agentWakeupRequests.agentId, input.agentId),
|
||||
eq(agentWakeupRequests.runId, input.runId),
|
||||
eq(agentWakeupRequests.source, "automation"),
|
||||
eq(agentWakeupRequests.reason, "issue_commented"),
|
||||
ne(agentWakeupRequests.status, "skipped"),
|
||||
sql`${agentWakeupRequests.idempotencyKey} = 'connection-intent:' || ${issueThreadInteractions.id}::text || ':' || ${issueThreadInteractions.status}`,
|
||||
sql`${agentWakeupRequests.payload}->>'issueId' = ${input.issueId}`,
|
||||
sql`${agentWakeupRequests.payload}->>'interactionId' = ${input.interactionId}`,
|
||||
sql`${agentWakeupRequests.payload}->>'interactionKind' = 'connection_intent'`,
|
||||
sql`${agentWakeupRequests.payload}->>'interactionStatus' = ${issueThreadInteractions.status}`,
|
||||
))
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
eq(issueThreadInteractions.issueId, input.issueId),
|
||||
eq(issueThreadInteractions.id, input.interactionId),
|
||||
eq(issueThreadInteractions.kind, "connection_intent"),
|
||||
or(inArray(issueThreadInteractions.status, ["accepted", "rejected"]), and(
|
||||
eq(issueThreadInteractions.status, "expired"),
|
||||
sql`${issueThreadInteractions.result}->>'outcome' = 'expired'`,
|
||||
sql`jsonb_typeof(${issueThreadInteractions.result}->'connectionId') = 'string'`,
|
||||
)),
|
||||
)).limit(1);
|
||||
return receipt?.sourceRunId ?? null;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { IssueThreadInteraction, RequestItemVerdictsInteraction } from "@paperclipai/shared";
|
||||
import { chatInteractionForm, chatItemVerdicts } from "./chat-interaction-forms.js";
|
||||
import { chatQuestionFormActionRecords, createChatQuestionFormDraft, parseChatQuestionFormSubmitTokenPayload } from "./chat-question-forms.js";
|
||||
|
||||
function review(): RequestItemVerdictsInteraction {
|
||||
return {
|
||||
id: "review-1", kind: "request_item_verdicts", status: "pending", summary: "Review the migration",
|
||||
payload: { version: 1, prompt: "Check both artifacts", items: [{ id: "api", label: "API", description: "The route" }, { id: "docs", label: "Docs" }], verdicts: ["approve", "reject"], requireReasonOn: ["reject"] },
|
||||
result: null,
|
||||
} as RequestItemVerdictsInteraction;
|
||||
}
|
||||
|
||||
describe("Slack item reviews", () => {
|
||||
it("maps opaque form values to canonical verdicts and requires the reason for rejection", () => {
|
||||
const source = review();
|
||||
const view = chatInteractionForm(source, "slack")!;
|
||||
expect(view.summary).toContain(source.payload.prompt);
|
||||
const draft = createChatQuestionFormDraft(view, { nativeProvider: "slack" })!;
|
||||
const payload = parseChatQuestionFormSubmitTokenPayload(chatQuestionFormActionRecords(draft, { companyId: "company", conversationId: "conversation", endpointId: "endpoint", publicationId: "publication" })[1].payload)!;
|
||||
const answers = [{ questionId: "verdict:api", optionIds: ["approve"] }, { questionId: "verdict:docs", optionIds: ["reject"] }];
|
||||
const missing = chatItemVerdicts(source, answers, payload)!;
|
||||
const reasonField = payload.fields.find(field => field.questionId === "reason:docs")!;
|
||||
expect(missing.errors).toEqual({ [reasonField.fieldId]: "Explain why you chose reject." });
|
||||
const complete = chatItemVerdicts(source, [...answers, { questionId: "reason:docs", optionIds: [], otherText: " Missing examples " }], payload)!;
|
||||
expect(complete).toEqual({ errors: {}, verdicts: [{ id: "api", verdict: "approve" }, { id: "docs", verdict: "reject", reason: "Missing examples" }] });
|
||||
expect(chatItemVerdicts(source, [{ questionId: "verdict:api", optionIds: ["approve"] }, { questionId: "verdict:docs", optionIds: ["approve"] }], payload)!.errors).toEqual({});
|
||||
});
|
||||
|
||||
it("shows only unresolved items and preserves full context", () => {
|
||||
const source = review();
|
||||
source.result = { version: 1, outcome: "resolved", complete: false, items: [{ id: "api", verdict: "approve" }] } as RequestItemVerdictsInteraction["result"];
|
||||
const view = chatInteractionForm(source, "slack")!;
|
||||
expect(view.payload.questions.map(question => question.id)).toEqual(["verdict:docs", "reason:docs"]);
|
||||
expect(source.kind).toBe("request_item_verdicts");
|
||||
});
|
||||
|
||||
it("keeps unsupported providers, attachments, and oversize reviews on the complete surface", () => {
|
||||
const source = review();
|
||||
expect(chatInteractionForm(source, "telegram")).toBeNull();
|
||||
expect(chatInteractionForm({ ...source, payload: { ...source.payload, items: [{ id: "api", label: "API", attachmentId: "attachment" }] } } as IssueThreadInteraction, "slack")).toBeNull();
|
||||
expect(chatInteractionForm({ ...source, payload: { ...source.payload, items: Array.from({ length: 13 }, (_, i) => ({ id: `item-${i}`, label: `Item ${i}` })) } }, "slack")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { AskUserQuestionsInteraction, AskUserQuestionsAnswer, IssueThreadInteraction, RequestItemVerdictValue } from "@paperclipai/shared";
|
||||
import type { ChatQuestionFormSubmitTokenPayload } from "./chat-question-forms.js";
|
||||
|
||||
/** A view model for the existing form transport; canonical interactions keep their original kind. */
|
||||
export function chatInteractionForm(interaction: IssueThreadInteraction, provider: string): AskUserQuestionsInteraction | null {
|
||||
if (interaction.kind === "ask_user_questions") return interaction;
|
||||
if (provider !== "slack" || interaction.kind !== "request_item_verdicts" || interaction.payload.target ||
|
||||
interaction.payload.items.length > 12 || interaction.payload.items.some(item => item.attachmentId)) return null;
|
||||
const remaining = interaction.payload.items.filter(item => !interaction.result?.items.some(result => result.id === item.id));
|
||||
if (!remaining.length) return null;
|
||||
const verdicts = interaction.payload.verdicts ?? ["approve", "reject"];
|
||||
const reasons = interaction.payload.requireReasonOn ?? [];
|
||||
return { ...interaction, kind: "ask_user_questions", result: null, summary: [interaction.summary, interaction.payload.prompt, interaction.payload.detailsMarkdown].filter(Boolean).join("\n\n"), payload: {
|
||||
version: 1, title: "Review decisions",
|
||||
questions: remaining.flatMap(item => [
|
||||
{ id: `verdict:${item.id}`, prompt: item.label, helpText: [item.description, item.previewMarkdown, item.href].filter(Boolean).join("\n\n"), required: true, selectionMode: "single" as const,
|
||||
options: verdicts.map(verdict => ({ id: verdict, label: verdict[0].toUpperCase() + verdict.slice(1) })) },
|
||||
{ id: `reason:${item.id}`, prompt: `${interaction.payload.reasonLabel ?? "Reason"}: ${item.label}`, helpText: reasons.length ? `Required for: ${reasons.join(", ")}.` : "Optional explanation.", required: false, selectionMode: "single" as const, allowOther: true,
|
||||
options: [{ id: "text", label: "Reason", freeText: true }] },
|
||||
]),
|
||||
} };
|
||||
}
|
||||
|
||||
export function chatItemVerdicts(interaction: IssueThreadInteraction, answers: AskUserQuestionsAnswer[], payload: ChatQuestionFormSubmitTokenPayload) {
|
||||
if (interaction.kind !== "request_item_verdicts") return null;
|
||||
const errors: Record<string, string> = {};
|
||||
const verdicts = interaction.payload.items.filter(item => !interaction.result?.items.some(result => result.id === item.id)).map(item => {
|
||||
const verdict = answers.find(answer => answer.questionId === `verdict:${item.id}`)?.optionIds[0] as RequestItemVerdictValue;
|
||||
const reason = answers.find(answer => answer.questionId === `reason:${item.id}`)?.otherText?.trim();
|
||||
if (interaction.payload.requireReasonOn?.includes(verdict) && !reason) {
|
||||
const field = payload.fields.find(field => field.questionId === `reason:${item.id}`);
|
||||
if (field) errors[field.fieldId] = `Explain why you chose ${verdict}.`;
|
||||
}
|
||||
return { id: item.id, verdict, ...(reason ? { reason } : {}) };
|
||||
});
|
||||
return { verdicts, errors };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared";
|
||||
|
||||
export function chatInteractionPresentation(interaction: IssueThreadInteraction, options: { compactChoices?: boolean; nativeCustomAnswer?: boolean } = {}) {
|
||||
const context = [interaction.summary].filter((value): value is string => Boolean(value?.trim()));
|
||||
switch (interaction.kind) {
|
||||
case "ask_user_questions": {
|
||||
const { payload } = interaction;
|
||||
if (payload.questionSet?.description) context.push(payload.questionSet.description);
|
||||
for (const question of payload.questions) {
|
||||
const source = payload.questionSet?.questions.find(item => item.id === question.id);
|
||||
if (source?.header && source.header !== question.helpText && source.header !== question.prompt) context.push(source.header);
|
||||
context.push(question.prompt);
|
||||
if (question.helpText) context.push(question.helpText);
|
||||
for (const option of question.options.filter(item => !item.freeText || question.options.length > 1)) {
|
||||
if (options.nativeCustomAnswer && option.freeText) continue;
|
||||
const recommended = source?.options?.find(item => item.id === option.id)?.recommended;
|
||||
if (options.compactChoices && option.label.length <= 60 && (option.description?.length ?? 0) <= 75) continue;
|
||||
context.push(`- ${option.label}${recommended ? " (recommended)" : ""}${option.description ? `: ${option.description}` : ""}`);
|
||||
}
|
||||
if (!options.compactChoices && !options.nativeCustomAnswer && (source?.customAnswer || (question.allowOther && question.options.some(item => !item.freeText)))) {
|
||||
context.push("You can also provide your own answer.");
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: payload.title ?? interaction.title ?? "Input needed",
|
||||
body: context.join("\n\n"),
|
||||
};
|
||||
}
|
||||
case "request_confirmation":
|
||||
case "request_checkbox_confirmation":
|
||||
case "request_item_verdicts": {
|
||||
const { payload } = interaction;
|
||||
context.push(payload.prompt);
|
||||
if (payload.detailsMarkdown) context.push(payload.detailsMarkdown);
|
||||
if (interaction.kind === "request_checkbox_confirmation") {
|
||||
const minimum = interaction.payload.minSelected ?? 0;
|
||||
const maximum = interaction.payload.maxSelected;
|
||||
if (minimum > 0 || maximum != null) context.push(minimum === maximum
|
||||
? `Select exactly ${minimum}.`
|
||||
: `Select ${minimum}–${maximum ?? interaction.payload.options.length} checks.`);
|
||||
for (const option of interaction.payload.options) {
|
||||
if (options.compactChoices && option.label.length <= 60 && (option.description?.length ?? 0) <= 75) continue;
|
||||
context.push(`- ${option.label}${option.description ? `: ${option.description}` : ""}`);
|
||||
}
|
||||
}
|
||||
if (interaction.kind === "request_item_verdicts") {
|
||||
for (const item of interaction.payload.items) {
|
||||
context.push(`- ${item.label}${item.description ? `: ${item.description}` : ""}`);
|
||||
}
|
||||
}
|
||||
return { title: payload.prompt, body: context.join("\n\n") };
|
||||
}
|
||||
case "suggest_tasks": {
|
||||
for (const task of interaction.payload.tasks.filter(item => !item.hiddenInPreview)) {
|
||||
if (options.compactChoices && task.title.length <= 60 && (task.description?.length ?? 0) <= 75) continue;
|
||||
context.push(`- ${task.title}${task.description ? `: ${task.description}` : ""}`);
|
||||
}
|
||||
return { title: interaction.title ?? "Review suggested tasks", body: context.join("\n\n") };
|
||||
}
|
||||
case "connection_intent": {
|
||||
const { payload } = interaction;
|
||||
context.push(`${payload.requestingAgentName} needs ${payload.serviceName}${payload.accessRequest ? ` (${payload.accessRequest.connectionName})` : ""}.`);
|
||||
context.push("The addressed user must review this connection request in Paperclip.");
|
||||
return { title: interaction.title ?? `Connect ${payload.serviceName}`, body: context.join("\n\n") };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the submitted response readable without publishing internal result metadata. */
|
||||
export function answeredChatQuestionBody(interaction: AskUserQuestionsInteraction): string {
|
||||
const lines = [...new Set([interaction.summary, interaction.payload.questionSet?.description]
|
||||
.filter((value): value is string => Boolean(value?.trim())))];
|
||||
for (const question of interaction.payload.questions) {
|
||||
const answer = interaction.result?.answers.find(item => item.questionId === question.id);
|
||||
const labels = (answer?.optionIds ?? []).flatMap(id => {
|
||||
const option = question.options.find(item => item.id === id && !item.freeText);
|
||||
return option ? [option.label] : [];
|
||||
});
|
||||
if (answer?.otherText?.trim()) labels.push(answer.otherText.trim());
|
||||
lines.push(question.prompt, labels.length ? `Answered: ${labels.join("; ")}` : "No answer provided (optional).");
|
||||
}
|
||||
return lines.join("\n\n");
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
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";
|
||||
@@ -16,6 +19,8 @@ import type {
|
||||
AskUserQuestionsQuestion,
|
||||
IssueThreadInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
SafeExternalChatCardAction,
|
||||
} from "@paperclipai/shared";
|
||||
import { projectSafeChatPublication } from "./chat-publication-projection.js";
|
||||
@@ -68,7 +73,34 @@ function terminalNativeInteractionCopy(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (interaction.kind !== "ask_user_questions") 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
|
||||
@@ -82,7 +114,7 @@ function terminalNativeInteractionCopy(
|
||||
(candidate) => candidate.id === answer.optionIds[0],
|
||||
)
|
||||
: null;
|
||||
const body = option ? `Answered: ${option.label}.` : "Answered.";
|
||||
const body = option ? `Answered: ${option.label}.` : answeredChatQuestionBody(interaction);
|
||||
return { body, text: body };
|
||||
}
|
||||
if (interaction.status === "cancelled") {
|
||||
@@ -149,13 +181,14 @@ export function telegramCallbackDataByteLength(
|
||||
|
||||
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 > MAX_NATIVE_QUESTION_OPTIONS ||
|
||||
question.options.length > maxOptions ||
|
||||
question.options.some((option) => option.freeText === true)
|
||||
) {
|
||||
return null;
|
||||
@@ -163,13 +196,36 @@ export function nativeChatQuestion(
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram can safely render ordinary binary confirmations as inline buttons.
|
||||
* 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 nativeTelegramConfirmation(
|
||||
export function nativeChatConfirmation(
|
||||
interaction: IssueThreadInteraction,
|
||||
): RequestConfirmationInteraction | null {
|
||||
if (interaction.kind !== "request_confirmation") return null;
|
||||
@@ -185,40 +241,6 @@ export function nativeTelegramConfirmation(
|
||||
return interaction;
|
||||
}
|
||||
|
||||
function textForQuestionInteraction(
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
taskUrl: string | null,
|
||||
): string {
|
||||
const lines = [
|
||||
interaction.payload.title ?? interaction.title ?? "Input needed",
|
||||
"",
|
||||
];
|
||||
if (interaction.summary) lines.push(interaction.summary, "");
|
||||
for (const question of interaction.payload.questions) {
|
||||
lines.push(question.prompt);
|
||||
if (question.helpText) lines.push(question.helpText);
|
||||
for (const option of question.options) lines.push(`- ${option.label}`);
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(
|
||||
taskUrl
|
||||
? `Open the task in Paperclip to respond: ${taskUrl}`
|
||||
: "Open the task in Paperclip to respond.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function genericInteractionText(taskUrl: string | null): string {
|
||||
return [
|
||||
"This task needs an authorized response in Paperclip.",
|
||||
taskUrl
|
||||
? `Open the task in Paperclip to respond: ${taskUrl}`
|
||||
: "Open the task in Paperclip to respond.",
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues one immutable outbox item per live external task binding. A lone
|
||||
* closed single-select question receives compact executable buttons. Slack and
|
||||
@@ -230,17 +252,6 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
interaction: IssueThreadInteraction,
|
||||
) {
|
||||
if (interaction.status !== "pending") return [];
|
||||
// The first native-chat wave intentionally externalizes only questions and
|
||||
// confirmations. Other governance interactions have richer partial and
|
||||
// terminal semantics that are authoritative in Paperclip; projecting a
|
||||
// generic link card without complete settlement/recovery would leave stale
|
||||
// provider prompts after a board decision.
|
||||
if (
|
||||
interaction.kind !== "ask_user_questions" &&
|
||||
interaction.kind !== "request_confirmation"
|
||||
) {
|
||||
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
|
||||
@@ -285,30 +296,46 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
if (bindings.length === 0) return [];
|
||||
|
||||
const taskUrl = publicChatInteractionTaskUrl(interaction.issueId);
|
||||
const question =
|
||||
interaction.kind === "ask_user_questions"
|
||||
? nativeChatQuestion(interaction)
|
||||
: null;
|
||||
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 =
|
||||
interaction.kind === "ask_user_questions" &&
|
||||
!requiresBoard && (!inlineSelection || customQuestion) && formInteraction &&
|
||||
(endpoint.provider === "slack" ||
|
||||
endpoint.provider === "discord" ||
|
||||
endpoint.provider === "microsoft-teams") &&
|
||||
endpoint.capabilities.actions === true &&
|
||||
endpoint.capabilities.modals === true
|
||||
? createChatQuestionFormDraft(
|
||||
interaction,
|
||||
endpoint.provider === "discord"
|
||||
? { nativeProvider: "discord" }
|
||||
formInteraction,
|
||||
(endpoint.provider === "discord" || endpoint.provider === "slack")
|
||||
? { nativeProvider: endpoint.provider }
|
||||
: {},
|
||||
)
|
||||
: null;
|
||||
const supportsCallbacks =
|
||||
endpoint.provider !== "imessage-photon" &&
|
||||
formDraft === null &&
|
||||
!requiresBoard && endpoint.provider !== "imessage-photon" &&
|
||||
(formDraft === null || customQuestion !== null) &&
|
||||
question !== null &&
|
||||
endpoint.capabilities.actions === true;
|
||||
const questionActionTokens = supportsCallbacks
|
||||
@@ -318,11 +345,11 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
}))
|
||||
: [];
|
||||
const confirmation =
|
||||
endpoint.provider === "telegram" && endpoint.capabilities.actions === true
|
||||
? nativeTelegramConfirmation(interaction)
|
||||
!requiresBoard && (endpoint.provider === "telegram" || endpoint.provider === "slack") && endpoint.capabilities.actions === true
|
||||
? nativeChatConfirmation(interaction) ?? checklist
|
||||
: null;
|
||||
const confirmationActionTokens = confirmation
|
||||
? (["accept", "reject"] as const).map((decision) => ({
|
||||
const confirmationActionTokens = confirmation || suggestions || connectionDecline
|
||||
? (connectionDecline ? ["reject"] as const : ["accept", "reject"] as const).map((decision) => ({
|
||||
actionId: createChatConfirmationActionToken(),
|
||||
decision,
|
||||
}))
|
||||
@@ -339,31 +366,32 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
}
|
||||
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: "Respond",
|
||||
style: "primary" as const,
|
||||
label: customQuestion ? "Other…" : interaction.kind === "request_item_verdicts" ? "Review decisions" : "Respond",
|
||||
style: customQuestion ? "default" as const : "primary" as const,
|
||||
},
|
||||
]
|
||||
: supportsCallbacks
|
||||
? questionActionTokens.map(({ actionId, option }) => ({
|
||||
? (inlineSelection ? [] : questionActionTokens).map(({ actionId, option }) => ({
|
||||
type: "callback" as const,
|
||||
actionId,
|
||||
label: option.label,
|
||||
}))
|
||||
: confirmation
|
||||
? confirmationActionTokens.map(({ actionId, decision }) => ({
|
||||
: confirmation || suggestions || connectionDecline
|
||||
? confirmationActionTokens.filter(item => !(checklist || suggestedOptions.length > 1) || item.decision !== "accept").map(({ actionId, decision }) => ({
|
||||
type: "callback" as const,
|
||||
actionId,
|
||||
label:
|
||||
decision === "accept"
|
||||
? (confirmation.payload.acceptLabel ?? "Accept")
|
||||
: (confirmation.payload.rejectLabel ?? "Reject"),
|
||||
? (suggestions ? "Create task" : confirmation!.payload.acceptLabel ?? "Accept")
|
||||
: (connectionDecline ? "Not now" : suggestions ? "Dismiss" : confirmation!.payload.rejectLabel ?? "Reject"),
|
||||
style:
|
||||
decision === "accept"
|
||||
? ("primary" as const)
|
||||
: ("danger" as const),
|
||||
: (connectionDecline || suggestions ? "default" as const : "danger" as const),
|
||||
}))
|
||||
: taskUrl
|
||||
? [
|
||||
@@ -374,10 +402,9 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const text =
|
||||
interaction.kind === "ask_user_questions"
|
||||
? textForQuestionInteraction(interaction, taskUrl)
|
||||
: genericInteractionText(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",
|
||||
@@ -392,27 +419,29 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
: interaction.kind === "request_confirmation"
|
||||
? "confirmation"
|
||||
: "status",
|
||||
title:
|
||||
interaction.kind === "ask_user_questions"
|
||||
? (question?.prompt ??
|
||||
interaction.payload.title ??
|
||||
interaction.title ??
|
||||
"Input needed")
|
||||
: interaction.kind === "request_confirmation"
|
||||
? interaction.payload.prompt
|
||||
: "Response needed in Paperclip",
|
||||
body:
|
||||
interaction.kind === "ask_user_questions"
|
||||
? [...new Set([
|
||||
interaction.summary,
|
||||
...(question
|
||||
? [question.helpText]
|
||||
: interaction.payload.questions.flatMap(item => [item.prompt, item.helpText])),
|
||||
].filter((value): value is string => Boolean(value?.trim())))].join("\n\n") || undefined
|
||||
: interaction.kind === "request_confirmation"
|
||||
? (interaction.payload.detailsMarkdown ?? undefined)
|
||||
: "Open the task in Paperclip to review and respond.",
|
||||
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 } : {}) })),
|
||||
} } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -447,6 +476,15 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
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,
|
||||
@@ -471,7 +509,7 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
);
|
||||
} else if (
|
||||
publication &&
|
||||
confirmation &&
|
||||
(confirmation || suggestions || connectionDecline) &&
|
||||
confirmationActionTokens.length > 0
|
||||
) {
|
||||
const expiresAt = new Date(
|
||||
@@ -489,6 +527,8 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
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",
|
||||
@@ -625,6 +665,7 @@ export async function enqueueTerminalIssueInteractionChatPublications(
|
||||
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" &&
|
||||
!(
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface ChatPublicationProjectionInput {
|
||||
title: string;
|
||||
body?: string | null;
|
||||
actions?: readonly SafeExternalChatCardAction[] | null;
|
||||
selection?: SafeExternalChatCard["selection"];
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
@@ -323,6 +324,24 @@ function projectCard(
|
||||
);
|
||||
}
|
||||
|
||||
const selection = input.card.selection;
|
||||
if (selection && (typeof selection.actionId !== "string" || !SAFE_IDENTIFIER_RE.test(selection.actionId) ||
|
||||
!Array.isArray(selection.options) || selection.options.length < 1 || selection.options.length > 100 ||
|
||||
selection.options.some(option => !option || typeof option.actionId !== "string" || !SAFE_IDENTIFIER_RE.test(option.actionId)) ||
|
||||
new Set(selection.options.map(option => option.actionId)).size !== selection.options.length)) {
|
||||
throw new UnsafeChatPublicationError("External chat selection is invalid");
|
||||
}
|
||||
const safeSelection = selection ? {
|
||||
actionId: selection.actionId,
|
||||
...(selection.multiple ? { multiple: true, submitLabel: truncateByCodePoint(projectSafeChatPublicationText(selection.submitLabel ?? "Submit"), 75) } : {}),
|
||||
label: truncateByCodePoint(projectSafeChatPublicationText(selection.label), 150),
|
||||
options: selection.options.map(option => ({
|
||||
...(option.selected === true ? { selected: true } : {}),
|
||||
actionId: option.actionId,
|
||||
label: truncateByCodePoint(projectSafeChatPublicationText(option.label), 75),
|
||||
...(option.description ? { description: truncateByCodePoint(projectSafeChatPublicationText(option.description), 75) } : {}),
|
||||
})),
|
||||
} : undefined;
|
||||
const actions: SafeExternalChatCardAction[] = [];
|
||||
for (const action of rawActions) {
|
||||
const label = truncateByCodePoint(
|
||||
@@ -368,6 +387,7 @@ function projectCard(
|
||||
title,
|
||||
...(body ? { body } : {}),
|
||||
...(actions.length ? { actions } : {}),
|
||||
...(safeSelection ? { selection: safeSelection } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +99,57 @@ function interaction(
|
||||
}
|
||||
|
||||
describe("chat question forms", () => {
|
||||
it("preserves context, complete help and editable starting text in a native form", () => {
|
||||
const current = interaction();
|
||||
current.summary = "General checks passed; one detail is needed.";
|
||||
current.payload.questionSet!.description = "The answer determines the migration test.";
|
||||
current.payload.questionSet!.questions[1]!.initialText = "Editable draft";
|
||||
current.payload.questions[1]!.helpText = "Explain the expected behavior. ".repeat(12);
|
||||
const draft = createChatQuestionFormDraft(current)!;
|
||||
const payload = parseChatQuestionFormSubmitTokenPayload(chatQuestionFormActionRecords(draft, {
|
||||
companyId: current.companyId, endpointId: "endpoint", conversationId: "conversation", publicationId: "publication",
|
||||
})[1]!.payload)!;
|
||||
const modal = buildChatQuestionFormModal(current, draft.submitActionId, payload)!;
|
||||
const shown = JSON.stringify(modal);
|
||||
expect(shown).toContain(current.summary);
|
||||
expect(shown).toContain(current.payload.questionSet!.description);
|
||||
expect(shown).toContain(current.payload.questions[1]!.helpText!.trim());
|
||||
expect(modal.children.find(child => child.type === "text_input")).toMatchObject({ initialValue: "Editable draft" });
|
||||
});
|
||||
|
||||
it.each([13, 100])("opens a native select for all %i choices", count => {
|
||||
const current = interaction();
|
||||
current.payload.questions = [{ ...current.payload.questions[0]!, options: Array.from({ length: count }, (_, index) => ({ id: `choice-${index}`, label: `Choice ${index}` })) }];
|
||||
current.payload.questionSet!.questions = [{ ...current.payload.questionSet!.questions[0]!, options: current.payload.questions[0]!.options.map(({ id, label }) => ({ id, label })) }];
|
||||
expect(createChatQuestionFormDraft(current)?.fields[0]).toMatchObject({ kind: "single_select", options: expect.any(Array) });
|
||||
});
|
||||
|
||||
it("makes the original conversation audience explicit before a Slack modal answer", () => {
|
||||
const current = interaction();
|
||||
const draft = createChatQuestionFormDraft(current, { nativeProvider: "slack" })!;
|
||||
const payload = parseChatQuestionFormSubmitTokenPayload(chatQuestionFormActionRecords(draft, {
|
||||
companyId: current.companyId, endpointId: "endpoint", conversationId: "conversation", publicationId: "publication",
|
||||
})[1]!.payload)!;
|
||||
const modal = buildChatQuestionFormModal(current, draft.submitActionId, payload)!;
|
||||
expect(modal.children[0]).toMatchObject({ type: "text", content: "Your answers will be shared in this Slack conversation." });
|
||||
});
|
||||
|
||||
it.each([100, 101, 128])("keeps all %i custom-choice options reachable", count => {
|
||||
const current = interaction();
|
||||
const options = Array.from({ length: count }, (_, index) => ({ id: `choice-${index}`, label: `Choice ${index}` }));
|
||||
current.payload.questions = [{ ...current.payload.questions[0]!, allowOther: true, options: [...options, { id: "other", label: "Other", freeText: true }] }];
|
||||
current.payload.questionSet!.questions = [{ ...current.payload.questionSet!.questions[0]!, options, customAnswer: { enabled: true } }];
|
||||
const draft = createChatQuestionFormDraft(current, { nativeProvider: "slack" });
|
||||
if (count <= 100) expect(draft?.fields[0]).toMatchObject({ kind: "text" });
|
||||
else expect(draft).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an oversized editable starting value in the complete Paperclip form", () => {
|
||||
const current = interaction();
|
||||
current.payload.questionSet!.questions[1]!.initialText = "a".repeat(3001);
|
||||
expect(createChatQuestionFormDraft(current)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Discord's 4000-character contract explicit without changing legacy provider limits", () => {
|
||||
const current = interaction();
|
||||
current.payload.questionSet!.questions[1]!.textValidation = {
|
||||
@@ -157,7 +208,7 @@ describe("chat question forms", () => {
|
||||
),
|
||||
},
|
||||
];
|
||||
expect(createChatQuestionFormDraft(current)).toBeNull();
|
||||
expect(createChatQuestionFormDraft(current)?.fields).toHaveLength(1);
|
||||
const draft = createChatQuestionFormDraft(current, {
|
||||
nativeProvider: "discord",
|
||||
});
|
||||
@@ -350,6 +401,12 @@ describe("chat question forms", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a credential-bearing starting draft in Paperclip instead of exposing it to a chat modal", () => {
|
||||
const current = interaction();
|
||||
current.payload.questionSet!.questions[1]!.initialText = "xoxb-123456789012345678901234567890";
|
||||
expect(createChatQuestionFormDraft(current)).toBeNull();
|
||||
});
|
||||
|
||||
it("issues opaque open, submit, field, and option tokens as durable action rows", () => {
|
||||
const now = new Date("2026-09-05T12:00:00.000Z");
|
||||
const draft = createChatQuestionFormDraft(interaction(), { now });
|
||||
@@ -422,6 +479,7 @@ describe("chat question forms", () => {
|
||||
{ label: "Production", value: expect.stringMatching(/^pcfo:/) },
|
||||
],
|
||||
},
|
||||
{ type: "text", content: "Describe the user-facing change" },
|
||||
{
|
||||
type: "text_input",
|
||||
id: draft.fields[1]?.fieldId,
|
||||
@@ -455,6 +513,7 @@ describe("chat question forms", () => {
|
||||
{ title: "Production", value: expect.stringMatching(/^pcfo:/) },
|
||||
],
|
||||
},
|
||||
{ type: "TextBlock", text: "Describe the user-facing change" },
|
||||
{
|
||||
type: "Input.Text",
|
||||
id: draft.fields[1]?.fieldId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectSafeChatPublicationText } from "./chat-publication-projection.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -10,7 +11,9 @@ import type {
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
Modal,
|
||||
CardText,
|
||||
Select,
|
||||
RadioSelect,
|
||||
SelectOption,
|
||||
TextInput,
|
||||
type ModalElement,
|
||||
@@ -61,7 +64,7 @@ export type ChatQuestionFormField =
|
||||
ChatQuestionFormTextField | ChatQuestionFormSelectField;
|
||||
|
||||
export interface ChatQuestionFormDraft {
|
||||
nativeProvider?: "discord";
|
||||
nativeProvider?: "discord" | "slack";
|
||||
expiresAt: string;
|
||||
fields: ChatQuestionFormField[];
|
||||
interactionId: string;
|
||||
@@ -78,7 +81,7 @@ export interface ChatQuestionFormOpenTokenPayload {
|
||||
}
|
||||
|
||||
export interface ChatQuestionFormSubmitTokenPayload {
|
||||
nativeProvider?: "discord";
|
||||
nativeProvider?: "discord" | "slack";
|
||||
expiresAt: string;
|
||||
fields: ChatQuestionFormField[];
|
||||
formActionId: string;
|
||||
@@ -159,10 +162,9 @@ function opaqueToken(prefix: string): string {
|
||||
}
|
||||
|
||||
function boundedLabel(value: string, maximum: number): string {
|
||||
return value
|
||||
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
||||
.trim()
|
||||
.slice(0, maximum);
|
||||
return Array.from(value.replace(/[\u0000-\u001f\u007f]/g, " ").trim())
|
||||
.slice(0, maximum)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -214,14 +216,21 @@ function questionSetQuestion(
|
||||
function formKind(
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
question: AskUserQuestionsQuestion,
|
||||
nativeProvider?: "discord" | "slack",
|
||||
): "single_select" | "text" | null {
|
||||
const source = questionSetQuestion(interaction, question.id);
|
||||
if (source) {
|
||||
if (nativeProvider === "slack" && source.answerMode === "single_select" && source.customAnswer && interaction.payload.questions.length === 1) {
|
||||
// The text modal is the Other action beside native choices. Without all
|
||||
// listed choices, keep the complete response path in Paperclip.
|
||||
const choiceCount = question.options.filter(option => !option.freeText).length;
|
||||
return choiceCount > 0 && choiceCount <= MAX_NATIVE_SELECT_OPTIONS ? "text" : null;
|
||||
}
|
||||
if (source.answerMode === "text") {
|
||||
// Regex evaluation supplied by an agent can be computationally unsafe.
|
||||
// Keep those uncommon forms in Paperclip, whose canonical validator owns
|
||||
// that policy, instead of evaluating a pattern inside a webhook callback.
|
||||
return source.textValidation?.pattern ? null : "text";
|
||||
// Slack can show canonical validation errors without closing the modal.
|
||||
// The canonical resolver evaluates patterns in its bounded worker, never
|
||||
// synchronously on the webhook event loop.
|
||||
return source.textValidation?.pattern && nativeProvider !== "slack" ? null : "text";
|
||||
}
|
||||
return source.answerMode === "single_select" && !source.customAnswer
|
||||
? "single_select"
|
||||
@@ -255,7 +264,7 @@ function formKind(
|
||||
*/
|
||||
export function createChatQuestionFormDraft(
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
options: { now?: Date; nativeProvider?: "discord" } = {},
|
||||
options: { now?: Date; nativeProvider?: "discord" | "slack" } = {},
|
||||
): ChatQuestionFormDraft | null {
|
||||
const maxTextLength =
|
||||
options.nativeProvider === "discord" ? 4000 : MAX_NATIVE_TEXT_LENGTH;
|
||||
@@ -270,14 +279,13 @@ export function createChatQuestionFormDraft(
|
||||
|
||||
const classified = interaction.payload.questions.map((question) => ({
|
||||
question,
|
||||
kind: formKind(interaction, question),
|
||||
kind: formKind(interaction, question, options.nativeProvider),
|
||||
}));
|
||||
if (classified.some(({ kind }) => kind === null)) return null;
|
||||
if (
|
||||
classified.length === 1 &&
|
||||
classified[0]?.kind === "single_select" &&
|
||||
(options.nativeProvider !== "discord" ||
|
||||
classified[0].question.options.length <= 12)
|
||||
classified[0].question.options.length <= 12
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -311,7 +319,8 @@ export function createChatQuestionFormDraft(
|
||||
const validation = source?.textValidation;
|
||||
const canonicalMax = validation?.maxLength ?? 100_000;
|
||||
const canonicalMin = validation?.minLength ?? 0;
|
||||
if (options.nativeProvider === "discord" && canonicalMax < 1) return null;
|
||||
if (canonicalMax < 1 || (source?.initialText?.length ?? 0) > Math.min(canonicalMax, maxTextLength)) return null;
|
||||
if (source?.initialText?.trim() && projectSafeChatPublicationText(source.initialText) !== source.initialText.trim()) return null;
|
||||
if (canonicalMin > maxTextLength) return null;
|
||||
fields.push({
|
||||
fieldId,
|
||||
@@ -331,7 +340,7 @@ export function createChatQuestionFormDraft(
|
||||
}
|
||||
|
||||
const now = options.now ?? new Date();
|
||||
return {
|
||||
const draft: ChatQuestionFormDraft = {
|
||||
...(options.nativeProvider
|
||||
? { nativeProvider: options.nativeProvider }
|
||||
: {}),
|
||||
@@ -343,6 +352,11 @@ export function createChatQuestionFormDraft(
|
||||
openActionId: opaqueToken(OPEN_ACTION_PREFIX),
|
||||
submitActionId: opaqueToken(SUBMIT_ACTION_PREFIX),
|
||||
};
|
||||
return buildChatQuestionFormModal(interaction, draft.submitActionId, {
|
||||
version: 1, interactionId: interaction.id, publicationId: "preview",
|
||||
formActionId: draft.submitActionId, expiresAt: draft.expiresAt,
|
||||
fields: draft.fields, ...(draft.nativeProvider ? { nativeProvider: draft.nativeProvider } : {}),
|
||||
}) ? draft : null;
|
||||
}
|
||||
|
||||
/** Values ready for one durable chat_actions insert. */
|
||||
@@ -475,7 +489,7 @@ export function parseChatQuestionFormSubmitTokenPayload(
|
||||
!isRecord(value) ||
|
||||
value.version !== 1 ||
|
||||
(value.nativeProvider !== undefined &&
|
||||
value.nativeProvider !== "discord") ||
|
||||
value.nativeProvider !== "discord" && value.nativeProvider !== "slack") ||
|
||||
typeof value.publicationId !== "string" ||
|
||||
typeof value.interactionId !== "string" ||
|
||||
!isChatQuestionFormSubmitActionId(value.formActionId) ||
|
||||
@@ -508,8 +522,8 @@ export function parseChatQuestionFormSubmitTokenPayload(
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(value.nativeProvider === "discord"
|
||||
? { nativeProvider: "discord" as const }
|
||||
...((value.nativeProvider === "discord" || value.nativeProvider === "slack")
|
||||
? { nativeProvider: value.nativeProvider }
|
||||
: {}),
|
||||
version: 1,
|
||||
publicationId: value.publicationId,
|
||||
@@ -540,28 +554,49 @@ export function buildChatQuestionFormModal(
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = [];
|
||||
const title = interaction.payload.title ?? interaction.title ?? "Input needed";
|
||||
const titleLimit = payload.nativeProvider === "discord" ? 45 : 24;
|
||||
const modalTitle = Array.from(title).length <= titleLimit ? boundedLabel(title, titleLimit)
|
||||
: payload.fields.length === 1 ? "Answer question" : "Answer questions";
|
||||
const children: ModalElement["children"] = [];
|
||||
const appendContext = (text: string | null | undefined) => {
|
||||
if (payload.nativeProvider === "discord" || !text?.trim()) return;
|
||||
const points = Array.from(projectSafeChatPublicationText(text));
|
||||
for (let offset = 0; offset < points.length; offset += 2000) {
|
||||
children.push(CardText(points.slice(offset, offset + 2000).join("")));
|
||||
}
|
||||
};
|
||||
if (payload.nativeProvider === "slack") appendContext("Your answers will be shared in this Slack conversation.");
|
||||
if (modalTitle !== title && !interaction.payload.questions.some(question => question.prompt === title)) appendContext(title);
|
||||
for (const text of new Set([interaction.summary, interaction.payload.questionSet?.description])) appendContext(text);
|
||||
for (const field of payload.fields) {
|
||||
const question = canonicalQuestion(interaction, field.questionId);
|
||||
if (!question || formKind(interaction, question) !== field.kind)
|
||||
if (!question || formKind(interaction, question, payload.nativeProvider) !== field.kind)
|
||||
return null;
|
||||
const label =
|
||||
boundedLabel(
|
||||
question.prompt,
|
||||
payload.nativeProvider === "discord" ? 45 : 2_000,
|
||||
) || "Response";
|
||||
const source = questionSetQuestion(interaction, question.id);
|
||||
if (source?.header && source.header !== question.helpText) appendContext(source.header);
|
||||
if (question.prompt !== label) appendContext(question.prompt);
|
||||
appendContext(question.helpText);
|
||||
if (field.kind === "text") {
|
||||
if ((source?.initialText?.length ?? 0) > field.maxLength) return null;
|
||||
if (source?.initialText?.trim() && projectSafeChatPublicationText(source.initialText) !== source.initialText.trim()) return null;
|
||||
children.push(
|
||||
TextInput({
|
||||
...(source?.initialText !== undefined ? { initialValue: source.initialText } : {}),
|
||||
id: field.fieldId,
|
||||
label,
|
||||
label: source?.customAnswer?.label ?? label,
|
||||
maxLength: field.maxLength,
|
||||
multiline: field.inputType === "text",
|
||||
optional: !field.required,
|
||||
...(question.helpText
|
||||
...((source?.customAnswer?.placeholder ?? question.helpText)
|
||||
? {
|
||||
placeholder: boundedLabel(
|
||||
question.helpText,
|
||||
source?.customAnswer?.placeholder ?? question.helpText!,
|
||||
payload.nativeProvider === "discord" ? 100 : 150,
|
||||
),
|
||||
}
|
||||
@@ -570,6 +605,12 @@ export function buildChatQuestionFormModal(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const option of question.options) {
|
||||
const recommended = source?.options?.find(item => item.id === option.id)?.recommended;
|
||||
if (option.label !== boundedLabel(option.label, 75) || (option.description?.length ?? 0) > 75 || recommended) {
|
||||
appendContext(`${option.label}${recommended ? " (recommended)" : ""}${option.description ? `: ${option.description}` : ""}`);
|
||||
}
|
||||
}
|
||||
const mappedOptions = field.options.map((mapping) => {
|
||||
const option = question.options.find(
|
||||
(candidate) =>
|
||||
@@ -587,7 +628,7 @@ export function buildChatQuestionFormModal(
|
||||
});
|
||||
if (mappedOptions.some((option) => option === null)) return null;
|
||||
children.push(
|
||||
Select({
|
||||
(payload.nativeProvider === "slack" && mappedOptions.length <= 5 ? RadioSelect : Select)({
|
||||
id: field.fieldId,
|
||||
label,
|
||||
optional: !field.required,
|
||||
@@ -597,13 +638,10 @@ export function buildChatQuestionFormModal(
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.nativeProvider !== "discord" && children.length > 100) return null;
|
||||
return Modal({
|
||||
callbackId,
|
||||
title:
|
||||
boundedLabel(
|
||||
interaction.payload.title ?? interaction.title ?? "Input needed",
|
||||
payload.nativeProvider === "discord" ? 45 : 24,
|
||||
) || "Input needed",
|
||||
title: modalTitle || "Input needed",
|
||||
submitLabel:
|
||||
boundedLabel(interaction.payload.submitLabel ?? "Submit", 24) || "Submit",
|
||||
closeLabel: "Cancel",
|
||||
@@ -661,7 +699,7 @@ export function validateChatQuestionFormSubmission(args: {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
for (const field of payload.fields) {
|
||||
const question = canonicalQuestion(interaction, field.questionId);
|
||||
if (!question || formKind(interaction, question) !== field.kind) {
|
||||
if (!question || formKind(interaction, question, payload.nativeProvider) !== field.kind) {
|
||||
return invalid("stale_interaction");
|
||||
}
|
||||
const submitted = values[field.fieldId] ?? "";
|
||||
|
||||
@@ -716,12 +716,17 @@ export async function enqueueChatRunMilestones(
|
||||
and continuation_wake.run_id = ${heartbeatRuns.id}
|
||||
and continuation_wake.agent_id = ${heartbeatRuns.agentId}
|
||||
and continuation_wake.status <> 'skipped'
|
||||
and continuation_wake.idempotency_key = case
|
||||
and (
|
||||
(interaction.kind = 'request_item_verdicts'
|
||||
and continuation_wake.payload->>'interactionId' = interaction.id::text
|
||||
and continuation_wake.payload->>'sourceRunId' = interaction.source_run_id::text
|
||||
and continuation_wake.reason = 'issue_commented')
|
||||
or continuation_wake.idempotency_key = case
|
||||
when interaction.kind = 'ask_user_questions'
|
||||
and interaction.status = 'answered'
|
||||
then 'question-response:' || interaction.id::text
|
||||
else 'interaction:' || interaction.id::text || ':' || interaction.status
|
||||
end
|
||||
end)
|
||||
where interaction.company_id = ${heartbeatRuns.companyId}
|
||||
and interaction.issue_id::text = ${issueIdFromContext}
|
||||
and interaction.id::text = ${heartbeatRuns.contextSnapshot} ->> 'interactionId'
|
||||
@@ -732,11 +737,29 @@ export async function enqueueChatRunMilestones(
|
||||
and interaction.status in ('answered', 'cancelled')
|
||||
)
|
||||
or (
|
||||
interaction.kind = 'request_confirmation'
|
||||
interaction.kind in ('request_confirmation', 'request_checkbox_confirmation', 'suggest_tasks')
|
||||
and interaction.status in ('accepted', 'rejected', 'cancelled')
|
||||
)
|
||||
or (
|
||||
interaction.kind = 'request_item_verdicts'
|
||||
and interaction.status in ('pending', 'answered', 'cancelled')
|
||||
)
|
||||
)
|
||||
)`;
|
||||
const hasConnectionContinuation = sql<boolean>`(
|
||||
${heartbeatRuns.contextSnapshot}->>'source' = 'connection_intent.resolved' and exists (
|
||||
select 1 from connection_intent_deliveries delivery
|
||||
inner join issue_thread_interactions interaction on interaction.id = delivery.interaction_id
|
||||
and interaction.company_id = delivery.company_id
|
||||
inner join agent_wakeup_requests wake on wake.company_id = delivery.company_id
|
||||
and wake.run_id = ${heartbeatRuns.id} and wake.agent_id = ${heartbeatRuns.agentId}
|
||||
and wake.idempotency_key = 'connection-intent:' || interaction.id::text || ':' || interaction.status
|
||||
and wake.status <> 'skipped'
|
||||
where delivery.company_id = ${heartbeatRuns.companyId}
|
||||
and interaction.issue_id::text = ${issueIdFromContext}
|
||||
and interaction.id::text = ${heartbeatRuns.contextSnapshot}->>'interactionId'
|
||||
)
|
||||
)`;
|
||||
const hasMonitorContinuation = sql<boolean>`(
|
||||
${heartbeatRuns.contextSnapshot}->>'source' = 'issue.monitor' and exists (
|
||||
select 1 from agent_wakeup_requests monitor_wake
|
||||
@@ -839,6 +862,7 @@ export async function enqueueChatRunMilestones(
|
||||
"issue.interaction.respond",
|
||||
"issue.interaction.accept",
|
||||
"issue.interaction.reject",
|
||||
"issue.interaction.verdicts",
|
||||
"issue.interaction.cancel",
|
||||
"issue.interaction.withdraw",
|
||||
"external_chat.interaction.resolve",
|
||||
@@ -847,6 +871,7 @@ export async function enqueueChatRunMilestones(
|
||||
hasDirectInteractionContinuation,
|
||||
),
|
||||
hasQuestionContinuationTarget,
|
||||
hasConnectionContinuation,
|
||||
hasMonitorContinuation,
|
||||
hasTaskContinuation,
|
||||
),
|
||||
@@ -897,6 +922,7 @@ export async function enqueueChatRunMilestones(
|
||||
),
|
||||
),
|
||||
or(
|
||||
hasConnectionContinuation,
|
||||
hasMonitorContinuation,
|
||||
hasTaskContinuation,
|
||||
sql`exists (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isPassiveSlackAction, normalizeSlackSelectionAction, slackSelectionValues } from "./chat-channels/slack/cards.js";
|
||||
import { PhotonChatAdapter, parsePhotonThreadId } from "./photon/adapter.js";
|
||||
import { PhotonLineAuthentication } from "./photon/cloud.js";
|
||||
import { PhotonState } from "./photon/state.js";
|
||||
@@ -407,6 +408,8 @@ export interface ChatSdkMessageUpdatedCallbackEvent {
|
||||
export interface ChatSdkCallbackEvent<T> {
|
||||
endpointId: string;
|
||||
event: T;
|
||||
/** Provider-normalized selected opaque values from an explicit message submit. */
|
||||
selectionValues?: string[];
|
||||
provider: ChatSdkProvider;
|
||||
/** Runtime-assigned ingress context; never read from provider payloads. */
|
||||
transport?: "discord_gateway";
|
||||
@@ -1917,7 +1920,10 @@ function registerCallbacks(
|
||||
});
|
||||
}
|
||||
if (callbacks.onAction) {
|
||||
chat.onAction(async (event) => {
|
||||
chat.onAction(async (sourceEvent) => {
|
||||
if (provider === "slack" && isPassiveSlackAction(sourceEvent)) return;
|
||||
const event = provider === "slack" ? normalizeSlackSelectionAction(sourceEvent) : sourceEvent;
|
||||
const selectionValues = provider === "slack" ? slackSelectionValues(sourceEvent) : undefined;
|
||||
const transport = actionTransport();
|
||||
if (!acceptsProviderScope(event.raw)) {
|
||||
if (transport === "discord_gateway") {
|
||||
@@ -1937,6 +1943,7 @@ function registerCallbacks(
|
||||
provider,
|
||||
event,
|
||||
...(transport ? { transport } : {}),
|
||||
...(selectionValues ? { selectionValues } : {}),
|
||||
...(telegramCallback(event.raw)
|
||||
? { telegramCallback: telegramCallback(event.raw) }
|
||||
: {}),
|
||||
|
||||
@@ -31,7 +31,7 @@ const botUserId = "U-PAPERCLIP-BOT";
|
||||
type SubmitCallback = NonNullable<ChatSdkRuntimeCallbacks["onModalSubmit"]>;
|
||||
type SubmitEvent = Parameters<SubmitCallback>[0];
|
||||
|
||||
function formFixture() {
|
||||
function formFixture(configure?: (interaction: AskUserQuestionsInteraction) => void) {
|
||||
const interaction: AskUserQuestionsInteraction = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
@@ -105,6 +105,7 @@ function formFixture() {
|
||||
},
|
||||
},
|
||||
};
|
||||
configure?.(interaction);
|
||||
const draft = createChatQuestionFormDraft(interaction)!;
|
||||
const payload = parseChatQuestionFormSubmitTokenPayload(
|
||||
chatQuestionFormActionRecords(draft, {
|
||||
@@ -182,10 +183,10 @@ interface SlackView {
|
||||
|
||||
function submission(view: SlackView) {
|
||||
const select = view.blocks.find(
|
||||
(block) => block.element.type === "static_select",
|
||||
(block) => block.element?.type === "static_select",
|
||||
)!;
|
||||
const text = view.blocks.find(
|
||||
(block) => block.element.type === "plain_text_input",
|
||||
(block) => block.element?.type === "plain_text_input",
|
||||
)!;
|
||||
const selected = select.element.options!.find(
|
||||
(option) => option.text.text === "Production",
|
||||
@@ -239,13 +240,14 @@ describe("Slack native multi-question modal adapter-to-runtime boundary", () =>
|
||||
|
||||
async function harness(
|
||||
options: {
|
||||
configureForm?: (interaction: AskUserQuestionsInteraction) => void;
|
||||
onSubmit?: SubmitCallback;
|
||||
persistence?: ChatSdkStatePersistence;
|
||||
endpointId?: string;
|
||||
companyId?: string;
|
||||
} = {},
|
||||
) {
|
||||
const form = formFixture();
|
||||
const form = formFixture(options.configureForm);
|
||||
const views: SlackView[] = [];
|
||||
const openTriggers: Array<string | null> = [];
|
||||
const apiMethods: string[] = [];
|
||||
@@ -386,6 +388,31 @@ describe("Slack native multi-question modal adapter-to-runtime boundary", () =>
|
||||
};
|
||||
}
|
||||
|
||||
it("sends the full prompt, context and editable draft through the real Slack adapter", async () => {
|
||||
const test = await harness({ configureForm(current) {
|
||||
current.summary = "The migration checks passed; one detail remains.";
|
||||
current.payload.questionSet!.description = "Tell us which agent failed.";
|
||||
current.payload.questionSet!.questions[1]!.header = "Reproduction details";
|
||||
current.payload.questions[0]!.options[0]!.description = "Complete option explanation. ".repeat(8);
|
||||
current.payload.questionSet!.questions[0]!.options![0]!.recommended = true;
|
||||
current.payload.questions[1]!.helpText = "Send the agent link and error text, if available. ".repeat(8);
|
||||
current.payload.questionSet!.questions[1]!.initialText = "Editable starting answer";
|
||||
} });
|
||||
const view = await test.open();
|
||||
const wire = JSON.stringify(view);
|
||||
expect(wire).toContain(test.form.interaction.summary);
|
||||
expect(wire).toContain(test.form.interaction.payload.questionSet!.description);
|
||||
expect(wire).toContain(test.form.interaction.payload.questions[1]!.helpText!.trim());
|
||||
expect(wire).toContain("Reproduction details");
|
||||
expect(wire).toContain(test.form.interaction.payload.questions[0]!.options[0]!.description!.trim());
|
||||
expect(wire).toContain("recommended");
|
||||
const text = view.blocks.find(block => block.element?.type === "plain_text_input");
|
||||
expect(text?.element).toMatchObject({ initial_value: "Editable starting answer" });
|
||||
const response = await test.runtime.handleWebhook(signedRequest(submission(view)));
|
||||
expect(await response.json()).toEqual({ response_action: "clear" });
|
||||
expect(test.validations).toEqual([{ ok: true, answers: expect.any(Array) }]);
|
||||
});
|
||||
|
||||
it("round-trips the actual Paperclip modal, opaque metadata and Slack state into canonical answers", async () => {
|
||||
const test = await harness();
|
||||
const view = await test.open();
|
||||
@@ -463,7 +490,7 @@ describe("Slack native multi-question modal adapter-to-runtime boundary", () =>
|
||||
const view = await test.open();
|
||||
const submitted = submission(view);
|
||||
const field = view.blocks.find(
|
||||
(block) => block.element.type === "plain_text_input",
|
||||
(block) => block.element?.type === "plain_text_input",
|
||||
)!;
|
||||
submitted.view.state.values[field.block_id]![field.element.action_id] = {
|
||||
type: "plain_text_input",
|
||||
@@ -518,7 +545,7 @@ describe("Slack native multi-question modal adapter-to-runtime boundary", () =>
|
||||
};
|
||||
if (mode === "canonical_option") {
|
||||
const field = view.blocks.find(
|
||||
(block) => block.element.type === "static_select",
|
||||
(block) => block.element?.type === "static_select",
|
||||
)!;
|
||||
submitted.view.state.values[field.block_id]![field.element.action_id] =
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
|
||||
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
|
||||
import { issueThreadInteractionService, type InteractionResolutionMutationOptions } from "./issue-thread-interactions.js";
|
||||
import { lockToolConnectionRuntimeInputs, toolAccessService } from "./tool-access.js";
|
||||
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
||||
import { captureRunIdentity } from "./run-identity.js";
|
||||
@@ -1180,7 +1180,7 @@ export function connectionIntentService(db: Db) {
|
||||
interactionId: string,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
options: { bypassCurrentMembershipCheck?: boolean } = {},
|
||||
options: { bypassCurrentMembershipCheck?: boolean; mutationOptions?: InteractionResolutionMutationOptions } = {},
|
||||
) {
|
||||
const loaded = await loadIntent(interactionId);
|
||||
if (loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can decline this request");
|
||||
@@ -1194,6 +1194,7 @@ export function connectionIntentService(db: Db) {
|
||||
interactionId,
|
||||
{ version: 1, outcome: "declined", reason: reason?.trim() || null },
|
||||
{ userId },
|
||||
options.mutationOptions,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,8 @@ function record(value: unknown): Record<string, unknown> {
|
||||
|
||||
function requiresChatOriginProof(source: string): boolean {
|
||||
return source === "tool_action_review" || source === "issue.monitor" || source === "native_status_decision" ||
|
||||
source.startsWith("issue.comment") || source === "issue.update";
|
||||
source.startsWith("issue.comment") || source.startsWith("issue.interaction.") ||
|
||||
source === "external_chat.interaction.resolve" || source === "connection_intent.resolved" || source === "issue.update";
|
||||
}
|
||||
|
||||
export function isExternalChatPresentationContext(
|
||||
|
||||
@@ -197,7 +197,7 @@ export type IssueThreadInteractionServiceOptions = {
|
||||
};
|
||||
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type InteractionResolutionMutationOptions = {
|
||||
export type InteractionResolutionMutationOptions = {
|
||||
/** Confirmation accept/reject nested in an outer transaction must defer these
|
||||
* effects and flush them with the root database only after its commit. */
|
||||
deferConfirmationCommitEffects?: (effect: (committedDb: Db) => Promise<void>) => void;
|
||||
@@ -2634,7 +2634,9 @@ export function issueThreadInteractionService(
|
||||
},
|
||||
);
|
||||
}
|
||||
return hydrateInteraction(existing) as ConnectionIntentInteraction;
|
||||
const interaction = hydrateInteraction(existing) as ConnectionIntentInteraction;
|
||||
await enqueueIssueInteractionChatPublications(db, interaction);
|
||||
return interaction;
|
||||
}
|
||||
|
||||
let inserted = false;
|
||||
@@ -2662,7 +2664,10 @@ export function issueThreadInteractionService(
|
||||
));
|
||||
const reusable = pending.find((candidate) =>
|
||||
connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose && isDeepStrictEqual(connectionIntentPayloadSchema.parse(candidate.payload).accessRequest, payload.accessRequest));
|
||||
if (reusable) return reusable;
|
||||
if (reusable) {
|
||||
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(reusable));
|
||||
return reusable;
|
||||
}
|
||||
|
||||
const [sourceRun] = await tx.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, input.sourceRunId), eq(heartbeatRuns.companyId, issue.companyId)));
|
||||
@@ -2723,7 +2728,7 @@ export function issueThreadInteractionService(
|
||||
await tx
|
||||
.delete(toolOauthStates)
|
||||
.where(inArray(toolOauthStates.interactionId, supersededIds));
|
||||
await tx
|
||||
const superseded = await tx
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
@@ -2735,10 +2740,15 @@ export function issueThreadInteractionService(
|
||||
resolvedAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(inArray(issueThreadInteractions.id, supersededIds));
|
||||
.where(inArray(issueThreadInteractions.id, supersededIds))
|
||||
.returning();
|
||||
for (const previous of superseded) {
|
||||
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(previous));
|
||||
}
|
||||
}
|
||||
await touchIssue(tx, issue.id);
|
||||
inserted = true;
|
||||
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(row));
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
|
||||
return row;
|
||||
});
|
||||
@@ -2790,6 +2800,7 @@ export function issueThreadInteractionService(
|
||||
? NonNullable<T>
|
||||
: never,
|
||||
actor: InteractionActor,
|
||||
mutationOptions: InteractionResolutionMutationOptions = {},
|
||||
) => {
|
||||
const result = connectionIntentResultSchema.parse(resultInput);
|
||||
const current = await getPendingInteractionForResolution({
|
||||
@@ -2812,6 +2823,7 @@ export function issueThreadInteractionService(
|
||||
: "expired";
|
||||
const resolvedAt = now();
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
const [row] = await tx
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
@@ -2835,6 +2847,8 @@ export function issueThreadInteractionService(
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
|
||||
await tx.insert(connectionIntentDeliveries).values({ interactionId, companyId: issue.companyId }).onConflictDoNothing();
|
||||
}
|
||||
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(row));
|
||||
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(row));
|
||||
return row;
|
||||
});
|
||||
if (!updated) throw interactionAlreadyResolvedError();
|
||||
@@ -3867,6 +3881,7 @@ export function issueThreadInteractionService(
|
||||
interactionId,
|
||||
data,
|
||||
actor,
|
||||
mutationOptions,
|
||||
);
|
||||
case "request_confirmation": {
|
||||
await assertIssueWorkspaceFinalizedForAccept({
|
||||
@@ -3924,6 +3939,7 @@ export function issueThreadInteractionService(
|
||||
interactionId: string,
|
||||
input: AcceptIssueThreadInteraction,
|
||||
actor: InteractionActor,
|
||||
mutationOptions: InteractionResolutionMutationOptions = {},
|
||||
) => {
|
||||
assertIssueOpenForInteractionResolution(issue);
|
||||
const current = await db
|
||||
@@ -4007,6 +4023,7 @@ export function issueThreadInteractionService(
|
||||
const createdWakeTargets: IssueWakeTarget[] = [];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
const resolvedAt = new Date();
|
||||
const [claimed] = await tx
|
||||
@@ -4097,6 +4114,8 @@ export function issueThreadInteractionService(
|
||||
.returning();
|
||||
|
||||
await touchIssue(tx, issue.id);
|
||||
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
|
||||
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
|
||||
current.status = updated.status;
|
||||
current.result = updated.result;
|
||||
current.resolvedByAgentId = updated.resolvedByAgentId;
|
||||
@@ -4136,6 +4155,7 @@ export function issueThreadInteractionService(
|
||||
data,
|
||||
actor,
|
||||
current,
|
||||
mutationOptions,
|
||||
);
|
||||
case "request_confirmation":
|
||||
case "request_checkbox_confirmation":
|
||||
@@ -4158,6 +4178,7 @@ export function issueThreadInteractionService(
|
||||
interactionId: string,
|
||||
input: SubmitIssueThreadInteractionVerdicts,
|
||||
actor: InteractionActor,
|
||||
mutationOptions: InteractionResolutionMutationOptions = {},
|
||||
): Promise<{
|
||||
interaction: IssueThreadInteraction;
|
||||
newlyResolvedItemIds: string[];
|
||||
@@ -4165,6 +4186,7 @@ export function issueThreadInteractionService(
|
||||
assertIssueOpenForInteractionResolution(issue);
|
||||
const data = submitIssueThreadInteractionVerdictsSchema.parse(input);
|
||||
const submission = await db.transaction(async (tx) => {
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
const current = await tx
|
||||
.select()
|
||||
@@ -4268,6 +4290,8 @@ export function issueThreadInteractionService(
|
||||
}
|
||||
|
||||
await touchIssue(tx, issue.id);
|
||||
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
|
||||
if (complete) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
|
||||
return {
|
||||
interaction: hydrateInteraction(updated),
|
||||
newlyResolvedItemIds,
|
||||
@@ -4288,6 +4312,7 @@ export function issueThreadInteractionService(
|
||||
input: RejectIssueThreadInteraction,
|
||||
actor: InteractionActor,
|
||||
current: IssueThreadInteractionRow,
|
||||
mutationOptions: InteractionResolutionMutationOptions = {},
|
||||
) => {
|
||||
assertIssueOpenForInteractionResolution(issue);
|
||||
if (
|
||||
@@ -4304,8 +4329,9 @@ export function issueThreadInteractionService(
|
||||
}
|
||||
|
||||
const [updated] = await db.transaction(async (tx) => {
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
return tx.update(issueThreadInteractions)
|
||||
const rows = await tx.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "rejected",
|
||||
result: {
|
||||
@@ -4325,6 +4351,9 @@ export function issueThreadInteractionService(
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (rows[0]) await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(rows[0]));
|
||||
if (rows[0]) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(rows[0]));
|
||||
return rows;
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
@@ -4597,7 +4626,8 @@ export function issueThreadInteractionService(
|
||||
}
|
||||
|
||||
for (const row of itemVerdictRows) {
|
||||
const [updated] = await db
|
||||
const updated = await db.transaction(async tx => {
|
||||
const [retired] = await tx
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
@@ -4614,6 +4644,9 @@ export function issueThreadInteractionService(
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (retired) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(retired));
|
||||
return retired;
|
||||
});
|
||||
if (updated) expired.push(hydrateInteraction(updated));
|
||||
}
|
||||
|
||||
@@ -4783,7 +4816,10 @@ export function issueThreadInteractionService(
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
if (updated) expired.push(updated);
|
||||
if (updated) {
|
||||
await enqueueTerminalIssueInteractionChatPublications(db, hydrateInteraction(updated));
|
||||
expired.push(updated);
|
||||
}
|
||||
}
|
||||
if (expired.length) await db.delete(toolOauthStates).where(inArray(toolOauthStates.interactionId, expired.map((item) => item.id)));
|
||||
return expired;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveConnectionIntentContinuationSource, resolveItemVerdictContinuationWake } from "./chat-interaction-continuation.js";
|
||||
import { insertIssueComment } from "./issue-comment-writes.js";
|
||||
import { measureRunTransaction } from "./execution-transaction-timing.js";
|
||||
import { readNativeMonitorSource } from "./native-runtime/native-monitor-delivery.js";
|
||||
@@ -1395,6 +1396,22 @@ export async function resolveChatOriginPublicationBindings(
|
||||
contextSnapshot = snapshot;
|
||||
break;
|
||||
}
|
||||
if (contextSource === "connection_intent.resolved") {
|
||||
const interactionId = readStringFromRecord(snapshot, "interactionId");
|
||||
if (!interactionId || !isUuidLike(interactionId)) return [];
|
||||
const sourceRunId = await resolveConnectionIntentContinuationSource(dbOrTx, {
|
||||
companyId, issueId, interactionId, runId: originRunId, agentId: lineageAgentId!,
|
||||
});
|
||||
if (!sourceRunId) return [];
|
||||
// An automation can request access on an already-linked Slack task.
|
||||
// Its validated receipt and published prompt preserve that conversation.
|
||||
const promptBindings = await resolvePublishedInteractionPromptBindings(
|
||||
dbOrTx, companyId, issueId, interactionId, lineageAgentId!,
|
||||
);
|
||||
if (promptBindings.length > 0) return promptBindings;
|
||||
originRunId = sourceRunId;
|
||||
continue;
|
||||
}
|
||||
if (contextSource === "issue.monitor") {
|
||||
const sourceRunId = await readNativeMonitorSource(dbOrTx, { companyId, issueId, runId: originRunId });
|
||||
if (!sourceRunId) return [];
|
||||
@@ -1411,6 +1428,7 @@ export async function resolveChatOriginPublicationBindings(
|
||||
contextSource !== "issue.interaction.respond" &&
|
||||
contextSource !== "issue.interaction.accept" &&
|
||||
contextSource !== "issue.interaction.reject" &&
|
||||
contextSource !== "issue.interaction.verdicts" &&
|
||||
contextSource !== "issue.interaction.cancel" &&
|
||||
contextSource !== "issue.interaction.withdraw" &&
|
||||
contextSource !== "external_chat.interaction.resolve"
|
||||
@@ -1430,6 +1448,7 @@ export async function resolveChatOriginPublicationBindings(
|
||||
.select({
|
||||
kind: issueThreadInteractions.kind,
|
||||
status: issueThreadInteractions.status,
|
||||
result: issueThreadInteractions.result,
|
||||
sourceRunId: issueThreadInteractions.sourceRunId,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
@@ -1446,6 +1465,7 @@ export async function resolveChatOriginPublicationBindings(
|
||||
rows: Array<{
|
||||
kind: string;
|
||||
status: string;
|
||||
result: unknown;
|
||||
sourceRunId: string | null;
|
||||
}>,
|
||||
) => rows[0] ?? null,
|
||||
@@ -1455,13 +1475,16 @@ export async function resolveChatOriginPublicationBindings(
|
||||
interaction.kind === "ask_user_questions" &&
|
||||
interaction.status === "answered";
|
||||
const isResolvedConfirmation =
|
||||
interaction.kind === "request_confirmation" &&
|
||||
["request_confirmation", "request_checkbox_confirmation", "suggest_tasks"].includes(interaction.kind) &&
|
||||
(interaction.status === "accepted" || interaction.status === "rejected");
|
||||
const isCancelledSupportedInteraction =
|
||||
interaction.status === "cancelled" &&
|
||||
(interaction.kind === "ask_user_questions" ||
|
||||
interaction.kind === "request_confirmation");
|
||||
["ask_user_questions", "request_confirmation", "request_checkbox_confirmation", "request_item_verdicts", "suggest_tasks"].includes(interaction.kind);
|
||||
const isItemVerdictResponse = interaction.kind === "request_item_verdicts" &&
|
||||
(interaction.status === "answered" || interaction.status === "pending") &&
|
||||
contextSource === "issue.interaction.verdicts";
|
||||
if (
|
||||
!isItemVerdictResponse &&
|
||||
!isAnsweredQuestion &&
|
||||
!isResolvedConfirmation &&
|
||||
!isCancelledSupportedInteraction
|
||||
@@ -1498,7 +1521,13 @@ export async function resolveChatOriginPublicationBindings(
|
||||
const continuationIdempotencyKey = isAnsweredQuestion
|
||||
? `question-response:${interactionId}`
|
||||
: `interaction:${interactionId}:${interaction.status}`;
|
||||
const durableWakeTarget = recordedTarget
|
||||
const itemVerdictWake = isItemVerdictResponse
|
||||
? await resolveItemVerdictContinuationWake(dbOrTx, {
|
||||
companyId, issueId, interactionId, sourceRunId, runId: originRunId,
|
||||
agentId: lineageAgentId!, result: interaction.result,
|
||||
})
|
||||
: null;
|
||||
const durableWakeTarget = isItemVerdictResponse ? itemVerdictWake : recordedTarget
|
||||
? null
|
||||
: await dbOrTx
|
||||
.select({ id: agentWakeupRequests.id })
|
||||
|
||||
@@ -75,6 +75,23 @@ const ids = (value: unknown): string[] =>
|
||||
? value.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
|
||||
function completedMultiChoiceMatches(question: Record<string, unknown>, answer: Record<string, unknown>, payload: Record<string, unknown>): boolean {
|
||||
const selected = ids(payload.selectionValues);
|
||||
const answered = ids(answer.optionIds);
|
||||
const mappings = Array.isArray(payload.selectionOptions) ? payload.selectionOptions.map(record) : [];
|
||||
const options = Array.isArray(question.options) ? question.options.map(record) : [];
|
||||
if (question.selectionMode !== "multi" || question.allowOther === true ||
|
||||
!Array.isArray(payload.selectionValues) || selected.length !== payload.selectionValues.length ||
|
||||
!Array.isArray(answer.optionIds) || answered.length !== answer.optionIds.length ||
|
||||
new Set(selected).size !== selected.length || new Set(answered).size !== answered.length ||
|
||||
mappings.length !== options.length || new Set(mappings.map(item => item.value)).size !== mappings.length ||
|
||||
new Set(mappings.map(item => item.optionId)).size !== mappings.length ||
|
||||
(question.required !== false && selected.length === 0) ||
|
||||
selected.length !== answered.length ||
|
||||
mappings.some(item => typeof item.value !== "string" || typeof item.optionId !== "string" || !options.some(option => option.id === item.optionId && option.freeText !== true))) return false;
|
||||
return selected.every((value, index) => mappings.find(item => item.value === value)?.optionId === answered[index]);
|
||||
}
|
||||
|
||||
function completedQuestionFormMatchesInteraction(
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
action: typeof chatActions.$inferSelect,
|
||||
@@ -159,7 +176,7 @@ function completedQuestionFormMatchesInteraction(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a durable provider answer or GitHub's authenticated Board fallback,
|
||||
* Resolve a durable provider answer or an authenticated Board fallback,
|
||||
* never a caller-supplied wake marker. This
|
||||
* is routing/reading authority only: it never checks out a task or resolves a
|
||||
* review. Callers retain their issue/run/actor and current destination checks.
|
||||
@@ -620,8 +637,8 @@ async function resolveQuestionResponseChain(
|
||||
sql`${chatActions.result}->>'interactionId' = ${interaction.id}`,
|
||||
),
|
||||
);
|
||||
// GitHub has no executable question callback. Its published link leads to
|
||||
// the ordinary authenticated Board answer route, whose pending→answered CAS
|
||||
// GitHub and Slack forms without native controls use the ordinary
|
||||
// authenticated Board answer route, whose pending→answered CAS
|
||||
// creates the response delivery above atomically. Do not fabricate a provider
|
||||
// action: bind that distinct receipt to the original linked person instead.
|
||||
const boardQuery = tx
|
||||
@@ -676,7 +693,7 @@ async function resolveQuestionResponseChain(
|
||||
and(
|
||||
eq(chatEndpoints.id, chatPublications.endpointId),
|
||||
eq(chatEndpoints.companyId, chatPublications.companyId),
|
||||
eq(chatEndpoints.provider, "github"),
|
||||
eq(chatEndpoints.provider, provider),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
@@ -709,7 +726,12 @@ async function resolveQuestionResponseChain(
|
||||
),
|
||||
),
|
||||
);
|
||||
const evidenceQuery = provider === "github" ? boardQuery : actionQuery;
|
||||
// Slack native actions remain authoritative when present. The Board query
|
||||
// excludes every native answer token, including unfinished ones; its result
|
||||
// is re-read under the same identity and row locks below.
|
||||
const boardAnswer = provider === "github" ||
|
||||
(provider === "slack" && (await boardQuery).length === 1);
|
||||
const evidenceQuery = boardAnswer ? boardQuery : actionQuery;
|
||||
let expectedPrincipalId: string | null = null;
|
||||
if (lockMode !== "read") {
|
||||
// Identity changes take this advisory lock before their row lock. Resolve
|
||||
@@ -773,7 +795,7 @@ async function resolveQuestionResponseChain(
|
||||
const card = record(record(publication.payload).card);
|
||||
const links = Array.isArray(card.actions) ? card.actions.map(record) : [];
|
||||
if (
|
||||
provider !== "github" ||
|
||||
!boardAnswer ||
|
||||
interaction.resolvedByRunId !== null ||
|
||||
publication.payload.progressState !== "waiting_for_input" ||
|
||||
card.kind !== "question" ||
|
||||
@@ -781,12 +803,14 @@ async function resolveQuestionResponseChain(
|
||||
links.some((link) => link.type !== "link") ||
|
||||
!publication.publishedAt ||
|
||||
publication.publishedAt > interaction.resolvedAt ||
|
||||
typeof boardGeneration !== "number" ||
|
||||
!Number.isSafeInteger(boardGeneration) ||
|
||||
boardGeneration < 0 ||
|
||||
boardRuntime.generation !== boardGeneration ||
|
||||
typeof boardRuntime.credentialFingerprint !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(boardRuntime.credentialFingerprint)
|
||||
(provider === "github" && (
|
||||
typeof boardGeneration !== "number" ||
|
||||
!Number.isSafeInteger(boardGeneration) ||
|
||||
boardGeneration < 0 ||
|
||||
boardRuntime.generation !== boardGeneration ||
|
||||
typeof boardRuntime.credentialFingerprint !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(boardRuntime.credentialFingerprint)
|
||||
))
|
||||
)
|
||||
return null;
|
||||
} else if (action.kind === "question_answer") {
|
||||
@@ -806,13 +830,16 @@ async function resolveQuestionResponseChain(
|
||||
record(action.result).interactionStatus !== "answered" ||
|
||||
!selectedAnswer ||
|
||||
!selectedQuestion ||
|
||||
selectedQuestion.selectionMode !== "single" ||
|
||||
ids(selectedAnswer.optionIds).length !== 1 ||
|
||||
ids(selectedAnswer.optionIds)[0] !== action.payload.optionId ||
|
||||
!Array.isArray(selectedQuestion.options) ||
|
||||
!selectedQuestion.options.some(
|
||||
(option) => record(option).id === action.payload.optionId,
|
||||
)
|
||||
!Array.isArray(answers) || answers.length !== 1 ||
|
||||
!Array.isArray(questions) || questions.length !== 1 ||
|
||||
Boolean(selectedAnswer.otherText) ||
|
||||
(Array.isArray(action.payload.selectionOptions)
|
||||
? provider !== "slack" || !completedMultiChoiceMatches(selectedQuestion, selectedAnswer, action.payload)
|
||||
: selectedQuestion.selectionMode !== "single" ||
|
||||
ids(selectedAnswer.optionIds).length !== 1 ||
|
||||
ids(selectedAnswer.optionIds)[0] !== action.payload.optionId ||
|
||||
!selectedQuestion.options.some(option => record(option).id === action.payload.optionId))
|
||||
)
|
||||
return null;
|
||||
} else if (action.kind === "photon_interaction") {
|
||||
@@ -892,10 +919,12 @@ async function resolveQuestionResponseChain(
|
||||
}
|
||||
: {
|
||||
boardResponse: {
|
||||
schema: "paperclip.github_board_question_response.v1",
|
||||
schema: `paperclip.${provider}_board_question_response.v1`,
|
||||
principalId: inbound.principalId,
|
||||
runtimeGeneration: boardGeneration,
|
||||
credentialFingerprint: boardRuntime.credentialFingerprint,
|
||||
...(provider === "github" ? {
|
||||
runtimeGeneration: boardGeneration,
|
||||
credentialFingerprint: boardRuntime.credentialFingerprint,
|
||||
} : {}),
|
||||
},
|
||||
}),
|
||||
publication: {
|
||||
|
||||
@@ -467,7 +467,7 @@ describe("native external-chat response wait", () => {
|
||||
fixture: Awaited<ReturnType<typeof seedWaitTurn>>;
|
||||
gate: Awaited<ReturnType<typeof seedPriorCompletionReview>>;
|
||||
},
|
||||
responseKind: "button" | "form" = "button",
|
||||
responseKind: "button" | "form" | "multi" = "button",
|
||||
) {
|
||||
const fixture = target?.fixture ?? await seedWaitTurn(provider);
|
||||
const gate = target?.gate ?? await seedPriorCompletionReview(fixture);
|
||||
@@ -520,7 +520,7 @@ describe("native external-chat response wait", () => {
|
||||
{
|
||||
id: "color",
|
||||
prompt: "Choose a color",
|
||||
selectionMode: "single" as const,
|
||||
selectionMode: responseKind === "multi" ? "multi" as const : "single" as const,
|
||||
required: true,
|
||||
allowOther: false,
|
||||
options: [
|
||||
@@ -545,7 +545,7 @@ describe("native external-chat response wait", () => {
|
||||
}
|
||||
: {
|
||||
version: 1 as const,
|
||||
answers: [{ questionId: "color", optionIds: ["cobalt"] }],
|
||||
answers: [{ questionId: "color", optionIds: responseKind === "multi" ? ["cobalt", "amber"] : ["cobalt"] }],
|
||||
};
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: sourceRunId,
|
||||
@@ -641,7 +641,7 @@ describe("native external-chat response wait", () => {
|
||||
interactionId,
|
||||
publicationId,
|
||||
questionId: "color",
|
||||
optionId: "cobalt",
|
||||
...(responseKind === "multi" ? { selectionOptions: [{ value: "opaque-cobalt", optionId: "cobalt" }, { value: "opaque-amber", optionId: "amber" }], selectionValues: ["opaque-cobalt", "opaque-amber"] } : { optionId: "cobalt" }),
|
||||
},
|
||||
result:
|
||||
responseKind === "form"
|
||||
@@ -700,8 +700,12 @@ describe("native external-chat response wait", () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function seedGitHubBoardAnsweredTurn(withLink = true) {
|
||||
const fixture = await seedWaitTurn("github");
|
||||
async function seedBoardAnsweredTurn(withLink = true, provider: "github" | "slack" = "github", oversized = false) {
|
||||
const fixture = await seedWaitTurn(provider);
|
||||
if (provider === "slack") {
|
||||
await db.update(chatEndpointResources).set({ type: "channel" }).where(eq(chatEndpointResources.id, fixture.resourceId));
|
||||
await db.update(chatConversations).set({ isDirectMessage: false }).where(eq(chatConversations.id, fixture.conversationId));
|
||||
}
|
||||
const gate = await seedPriorCompletionReview(fixture);
|
||||
const [current] = await db
|
||||
.select()
|
||||
@@ -756,12 +760,16 @@ describe("native external-chat response wait", () => {
|
||||
{
|
||||
id: "color",
|
||||
prompt: "Choose a color",
|
||||
selectionMode: "single",
|
||||
// Multi-select plus a custom answer intentionally needs Board.
|
||||
selectionMode: provider === "slack" ? "multi" : "single",
|
||||
required: true,
|
||||
allowOther: false,
|
||||
allowOther: provider === "slack" && !oversized,
|
||||
options: [
|
||||
{ id: "cobalt", label: "Cobalt" },
|
||||
{ id: "amber", label: "Amber" },
|
||||
...(oversized ? Array.from({ length: 9 }, (_, index) => ({
|
||||
id: `option-${index}`, label: `Option ${index}`, description: "Detail ".repeat(550),
|
||||
})) : []),
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -779,7 +787,7 @@ describe("native external-chat response wait", () => {
|
||||
.update(chatPublications)
|
||||
.set({
|
||||
state: "published",
|
||||
providerMessageId: "github-question",
|
||||
providerMessageId: `${provider}-question`,
|
||||
publishedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatPublications.idempotencyKey, publicationKey))
|
||||
@@ -858,10 +866,24 @@ describe("native external-chat response wait", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it.each([true, false])(
|
||||
"attests a native GitHub question answered in Board without inventing a provider action (link: %s)",
|
||||
async (withLink) => {
|
||||
const fixture = await seedGitHubBoardAnsweredTurn(withLink);
|
||||
it("resumes an oversized Slack multi-select answered through its Paperclip handoff", async () => {
|
||||
const fixture = await seedBoardAnsweredTurn(true, "slack", true);
|
||||
const [publication] = await db.select().from(chatPublications).where(eq(chatPublications.id, fixture.publicationId));
|
||||
expect(publication!.payload.card?.actions).toEqual([
|
||||
{ type: "link", label: "Open in Paperclip", url: expect.stringContaining(fixture.issueId) },
|
||||
]);
|
||||
expect(publication!.payload.card?.selection).toBeUndefined();
|
||||
await expect(db.select().from(chatActions).where(eq(chatActions.companyId, fixture.companyId))).resolves.toEqual([]);
|
||||
await attestAnswer(fixture);
|
||||
const responses = await materializeExternalChatQuestionResponseInput({ db, binding: fixture, contextSnapshot: fixture.context });
|
||||
expect(responses).toHaveLength(1);
|
||||
expect(JSON.stringify(responses)).toContain("cobalt");
|
||||
});
|
||||
|
||||
it.each((["github", "slack"] as const).flatMap(provider => [true, false].map(withLink => ({ provider, withLink }))))(
|
||||
"attests a native $provider question answered in Board without inventing a provider action (link: $withLink)",
|
||||
async ({ provider, withLink }) => {
|
||||
const fixture = await seedBoardAnsweredTurn(withLink, provider);
|
||||
await expect(
|
||||
db
|
||||
.select()
|
||||
@@ -879,7 +901,7 @@ describe("native external-chat response wait", () => {
|
||||
).not.toBeNull();
|
||||
await attestAnswer(fixture);
|
||||
expect(fixture.context.paperclipWake).toMatchObject({
|
||||
externalChatProvider: "github",
|
||||
externalChatProvider: provider,
|
||||
});
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
@@ -923,7 +945,7 @@ describe("native external-chat response wait", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
it.each((["github", "slack"] as const).flatMap(provider => ([
|
||||
"different_user",
|
||||
"viewer",
|
||||
"membership_revoked",
|
||||
@@ -938,10 +960,10 @@ describe("native external-chat response wait", () => {
|
||||
"endpoint_paused",
|
||||
"resource_revoked",
|
||||
"answer_hash",
|
||||
] as const)(
|
||||
"rejects GitHub Board answer authority after %s",
|
||||
async (change) => {
|
||||
const fixture = await seedGitHubBoardAnsweredTurn();
|
||||
] as const).filter(change => provider === "github" || (change !== "generation" && change !== "missing_runtime")).map(change => ({ provider, change }))))(
|
||||
"rejects $provider Board answer authority after $change",
|
||||
async ({ provider, change }) => {
|
||||
const fixture = await seedBoardAnsweredTurn(true, provider);
|
||||
if (change === "different_user") {
|
||||
const otherUser = `other-${randomUUID()}`;
|
||||
await db
|
||||
@@ -1080,10 +1102,10 @@ describe("native external-chat response wait", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["generation", "identity", "membership", "reach"] as const)(
|
||||
"rechecks GitHub Board answer %s at publication after native completion",
|
||||
async (change) => {
|
||||
const fixture = await seedGitHubBoardAnsweredTurn();
|
||||
it.each((["github", "slack"] as const).flatMap(provider => (["generation", "identity", "membership", "reach"] as const).filter(change => provider === "github" || change !== "generation").map(change => ({ provider, change }))))(
|
||||
"rechecks $provider Board answer $change at publication after native completion",
|
||||
async ({ provider, change }) => {
|
||||
const fixture = await seedBoardAnsweredTurn(true, provider);
|
||||
await attestAnswer(fixture);
|
||||
const resultJson = await finishReviewResponse(fixture);
|
||||
expect(
|
||||
@@ -1203,7 +1225,7 @@ describe("native external-chat response wait", () => {
|
||||
async function attestAnswer(
|
||||
fixture:
|
||||
| Awaited<ReturnType<typeof seedAnsweredChatTurn>>
|
||||
| Awaited<ReturnType<typeof seedGitHubBoardAnsweredTurn>>,
|
||||
| Awaited<ReturnType<typeof seedBoardAnsweredTurn>>,
|
||||
) {
|
||||
expect(
|
||||
await attestReviewedExternalChatRun({
|
||||
@@ -1240,13 +1262,13 @@ describe("native external-chat response wait", () => {
|
||||
check: (input: {
|
||||
fixture:
|
||||
| Awaited<ReturnType<typeof seedAnsweredChatTurn>>
|
||||
| Awaited<ReturnType<typeof seedGitHubBoardAnsweredTurn>>;
|
||||
| Awaited<ReturnType<typeof seedBoardAnsweredTurn>>;
|
||||
invoke: () => Promise<unknown>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const fixture =
|
||||
provider === "github"
|
||||
? await seedGitHubBoardAnsweredTurn()
|
||||
? await seedBoardAnsweredTurn()
|
||||
: await seedAnsweredChatTurn(provider);
|
||||
await attestAnswer(fixture);
|
||||
const root = await mkdtemp(path.join(tmpdir(), "answered-chat-file-"));
|
||||
@@ -1327,7 +1349,7 @@ describe("native external-chat response wait", () => {
|
||||
const fixture =
|
||||
source === "direct"
|
||||
? await seedWaitTurn("github")
|
||||
: await seedGitHubBoardAnsweredTurn();
|
||||
: await seedBoardAnsweredTurn();
|
||||
if (source === "direct") await seedPriorCompletionReview(fixture);
|
||||
// A previous run's failure projects `error` onto the agent. The next
|
||||
// already-claimed turn attests before execution-start changes it to
|
||||
@@ -1444,6 +1466,16 @@ describe("native external-chat response wait", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["valid", "tampered_selection", "revoked_identity"])("attests only the committed Slack multiple-choice response: %s", async condition => {
|
||||
const fixture = await seedAnsweredChatTurn("slack", undefined, "multi");
|
||||
if (condition === "tampered_selection") {
|
||||
const [action] = await db.select().from(chatActions).where(eq(chatActions.id, fixture.actionId));
|
||||
await db.update(chatActions).set({ payload: { ...action!.payload, selectionValues: ["opaque-amber", "opaque-cobalt"] } }).where(eq(chatActions.id, fixture.actionId));
|
||||
}
|
||||
if (condition === "revoked_identity") await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.principalId, fixture.principalId));
|
||||
expect(await attestReviewedExternalChatRun({ db, ...fixture, contextSnapshot: fixture.context })).toBe(condition === "valid");
|
||||
});
|
||||
|
||||
it("attests a genuine Slack mixed-question modal answer for its native continuation", async () => {
|
||||
const fixture = await seedAnsweredChatTurn("slack", undefined, "form");
|
||||
await expect(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { enqueueTerminalIssueInteractionChatPublications } from "./chat-interaction-publications.js";
|
||||
import { and, asc, eq, gt, inArray, isNull, or } from "drizzle-orm";
|
||||
import { approvals, companyMemberships, connectionIntentDeliveries, issueApprovals, issueThreadInteractions, issues, type Db } from "@paperclipai/db";
|
||||
import { connectionIntentPayloadSchema } from "@paperclipai/shared";
|
||||
@@ -111,10 +112,14 @@ export function satisfiedConnectionIntentService(db: Db) {
|
||||
if (!connection) return false;
|
||||
const now = new Date();
|
||||
// Retire the obsolete request without inventing a human approval.
|
||||
await tx.update(issueThreadInteractions).set({ status: "expired", resolvedAt: now, updatedAt: now,
|
||||
const [retired] = await tx.update(issueThreadInteractions).set({ status: "expired", resolvedAt: now, updatedAt: now,
|
||||
result: { version: 1, outcome: "expired", connectionId: connection.id,
|
||||
reason: "An already authorized connection is available to the requesting agent." },
|
||||
}).where(and(eq(issueThreadInteractions.id, intent.id), eq(issueThreadInteractions.status, "pending")));
|
||||
}).where(and(eq(issueThreadInteractions.id, intent.id), eq(issueThreadInteractions.status, "pending"))).returning();
|
||||
if (!retired) return false;
|
||||
const { issueThreadInteractionService } = await import("./issue-thread-interactions.js");
|
||||
const interaction = await issueThreadInteractionService(txDb).getById(retired.id);
|
||||
if (interaction) await enqueueTerminalIssueInteractionChatPublications(txDb, interaction);
|
||||
await tx.update(issues).set({ updatedAt: now }).where(and(eq(issues.id, task.id), eq(issues.companyId, task.companyId)));
|
||||
await tx.insert(connectionIntentDeliveries).values({ interactionId: intent.id, companyId: task.companyId }).onConflictDoNothing();
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
|
||||
|
||||
Reference in new issue
Block a user