mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +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
@@ -39,22 +39,25 @@ Do NOT install this on production Paperclip agents. This guide is for repository
|
||||
|
||||
## Mental model
|
||||
|
||||
Every issue-thread interaction has four moving parts:
|
||||
Every issue-thread interaction has five moving parts:
|
||||
|
||||
| Layer | Owns |
|
||||
|--------------|---------------------------------------------------------------------------------------------------|
|
||||
| Shared | Kind constant, payload/result interfaces, Zod validators, exported types, shared-test coverage. |
|
||||
| Server | Service create/accept/reject/respond, staleness, supersede, idempotency, activity log, wake send. |
|
||||
| UI | Card pending/resolved/stale states, fixtures, Storybook, issue-thread/IssueDetail wiring. |
|
||||
| Channels | Complete question/context, native response or Paperclip handoff, callback authorization, and settled provider cards. |
|
||||
| Helpers/Docs | CLI command, MCP tool, plugin SDK type+host+testing path, `skills/paperclip` guidance. |
|
||||
|
||||
The four existing kinds are the canonical prior art. Pick the closest one and
|
||||
copy its plumbing rather than inventing parallel mechanics:
|
||||
Use the current `ISSUE_THREAD_INTERACTION_KINDS` as the inventory. Pick the
|
||||
closest existing kind and reuse its plumbing rather than inventing parallel mechanics:
|
||||
|
||||
- `request_confirmation` — single yes/no bound to a `target` with stale/supersede.
|
||||
- `request_checkbox_confirmation` — bounded multi-select against an immutable option set.
|
||||
- `ask_user_questions` — small typed form, no target binding.
|
||||
- `suggest_tasks` — proposes tasks the board can accept individually.
|
||||
- `request_item_verdicts` — requests individual decisions on a set of items.
|
||||
- `suggest_tasks` — proposes tasks a permitted resolver can accept individually.
|
||||
- `connection_intent` — requests authorized connection setup or access.
|
||||
|
||||
If your new card needs target binding and a yes/no-style resolution, model it
|
||||
after the two `request_*` kinds. If it is a structured form, model it after
|
||||
@@ -130,15 +133,16 @@ Touch:
|
||||
|
||||
Server invariants:
|
||||
|
||||
- Board-only resolution. Agent-authored accept/reject must be rejected with
|
||||
the existing 403 path; do not add a per-kind bypass.
|
||||
- Resolver authority. Use the current resolver policy, company cap, and named
|
||||
addressee checks in the shared service. Governed actions retain their required
|
||||
human approval; neither a new kind nor a channel callback widens authority.
|
||||
- Company scoping. Reads, writes, expiration, and supersede must all filter
|
||||
by `companyId`. Never trust an `issueId` alone.
|
||||
- Stale target. If `target` was specified at create time and a newer revision
|
||||
lands, the interaction expires with `outcome: "stale_target"`. Do not write
|
||||
bespoke staleness — call the same helper the other `request_*` kinds use.
|
||||
- Supersede on user comment. Default `supersedeOnUserComment: true` unless
|
||||
the payload schema documents otherwise.
|
||||
- Supersede on user comment. Preserve the payload schema's default and explicit
|
||||
choice; an ordinary comment must not silently answer a pending question.
|
||||
- Idempotency. The deterministic `idempotencyKey` shape from the existing
|
||||
kinds (`<kind>:<issueId>:<decisionKey>:<revisionId>`) must be honored;
|
||||
duplicate POSTs must return the existing card, not stack.
|
||||
@@ -183,6 +187,24 @@ UI invariants:
|
||||
- Stale, superseded, and accepted states render distinct copy; reuse the
|
||||
existing resolution-component shell.
|
||||
|
||||
### 3a. Channel questions and review cards
|
||||
|
||||
Read the [chat channel connector contract](../../../doc/connections/CONNECTOR-PLAYBOOK.md#chat-channel-connectors).
|
||||
Every new kind or changed form needs a usable path through each supported
|
||||
conversation channel: complete safe question/context plus native response controls
|
||||
or an explicit link to the authorized Paperclip surface. Include runtime/composer
|
||||
variants; do not assume that adding a board card makes it available in Slack.
|
||||
|
||||
Extend the existing presentation, publication, response, and settlement owners.
|
||||
Preserve authorization and the existing continuation/delivery receipts. Cover
|
||||
resolved, rejected, expired, superseded, and recovery paths so a settled request
|
||||
cannot remain actionable in the channel. Keep dismissal distinct from resolution.
|
||||
|
||||
Run local serialization and lifecycle tests first, then verify affected forms in
|
||||
the actual provider with an authorized test conversation. Capture rendering,
|
||||
answer persistence, the intended continuation, and final card/result delivery.
|
||||
State clearly when provider HTTP was simulated or live acceptance is outstanding.
|
||||
|
||||
### 4. CLI, MCP, plugin SDK helpers
|
||||
|
||||
External callers must be able to create the new interaction without
|
||||
@@ -246,11 +268,14 @@ If UI vitest fails with `act is not a function`, the shell is running with
|
||||
- [ ] Service handles create, accept, reject/respond, stale-target,
|
||||
supersede-on-user-comment, idempotency, activity log, and continuation
|
||||
wake.
|
||||
- [ ] Routes honor board-only resolution and company scoping.
|
||||
- [ ] Routes and channel callbacks honor current resolver authority and company scoping.
|
||||
- [ ] UI renders pending, resolved, stale, disabled/submitting, and
|
||||
validation-error states; resolved-state large selections summarize by
|
||||
count first.
|
||||
- [ ] Fixtures and Storybook entries exist for the new kind.
|
||||
- [ ] Every supported channel has a native response or explicit Paperclip handoff
|
||||
for the form, with settlement and continuation coverage. Local simulations
|
||||
and real-provider acceptance are reported separately.
|
||||
- [ ] CLI, MCP, and plugin SDK helpers all accept the new payload shape and
|
||||
have test coverage.
|
||||
- [ ] `skills/paperclip/SKILL.md` and `references/api-reference.md` updated.
|
||||
|
||||
@@ -259,6 +259,32 @@ assignment, delegation, and origin-thread completion use normal Paperclip
|
||||
workflows. Identify missing workflow capabilities separately rather than adding
|
||||
a connector-specific task lifecycle.
|
||||
|
||||
## Questions and review cards through the channel
|
||||
|
||||
A connected conversation must carry every user-question form, including requests
|
||||
shown above the Paperclip composer and questions from the runtime. Follow the
|
||||
[chat channel contract](./CONNECTOR-PLAYBOOK.md#chat-channel-connectors) for the
|
||||
complete inventory, native-response versus Paperclip-handoff decision, current
|
||||
authorization, and resolution/continuation behavior. Show the actual question
|
||||
and useful context in the channel; a title and **Respond** button alone do not
|
||||
satisfy that contract. Unsupported native controls need an explicit, usable
|
||||
handoff, not a missing request.
|
||||
|
||||
Use the [eleven-experience inventory](./CONNECTOR-PLAYBOOK.md#question-experience-inventory)
|
||||
and [provider coverage index](./CHAT-QUESTION-COVERAGE.md) to record the best
|
||||
supported controls and justified handoffs. Grade functional correctness, good
|
||||
experience, and best feasible experience separately. Review actual unanswered,
|
||||
answering, and settled screens yourself; saved answers must remain readable
|
||||
within the conversation's authorized audience. A browser fallback does not pass
|
||||
the quality bar when a suitable native control avoids it.
|
||||
|
||||
The optional first-message test in onboarding does not replace engineering
|
||||
qualification of these conversation loops. Follow the
|
||||
[per-format evidence standard](./CONNECTOR-PLAYBOOK.md#retain-inspectable-per-format-evidence),
|
||||
including actual link clicks, saved responses, continuation and delivery receipts,
|
||||
and retained failures. Keep real-provider evidence separate from local simulations,
|
||||
live-model runs, and deployed verification.
|
||||
|
||||
## Apply and verify
|
||||
|
||||
For a requested redesign, identify the relevant principles and fix the concrete
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Chat channel question coverage index
|
||||
|
||||
This is the index of per-provider question qualification records. The
|
||||
[connector playbook](./CONNECTOR-PLAYBOOK.md#question-experience-inventory) owns
|
||||
the Q01–Q11 inventory, control-selection principles, and acceptance standard.
|
||||
Keep that inventory and this index in sync when a channel or question experience
|
||||
changes. Provider capability flags alone do not establish support for a form.
|
||||
|
||||
## Provider records
|
||||
|
||||
The provider list comes from `CHAT_PROVIDERS` in
|
||||
`packages/shared/src/types/chat-channels.ts`. Add a record for a new provider;
|
||||
update the affected record for a changed form. An unaudited row means this index
|
||||
has no qualification record for it, not that the provider lacks controls or that
|
||||
its existing implementation is broken.
|
||||
|
||||
| Provider | Q01–Q11 record | Scope and status |
|
||||
| --- | --- | --- |
|
||||
| Slack | [October 24, 2026 baseline](#slack-october-24-2026) | All eleven core experiences observed in real Slack against a local candidate; limited scope below. Later changes require affected-case remeasurement. |
|
||||
| GitHub | Not yet indexed | Audit all applicable question paths, including comment replies and authenticated handoffs; no qualification claim here. |
|
||||
| Discord | Not yet indexed | Audit all applicable question paths; no qualification claim here. |
|
||||
| Microsoft Teams | Not yet indexed | Audit all applicable question paths; no qualification claim here. |
|
||||
| Telegram | Not yet indexed | Audit all applicable question paths; no qualification claim here. |
|
||||
| Speko | Not yet indexed | Establish applicable conversational paths and equivalents for controls; no qualification claim here. |
|
||||
| AgentMail | Not yet indexed | Include email reply and authenticated handoff behavior; no qualification claim here. |
|
||||
| iMessage Photon | Not yet indexed | Audit all applicable question paths; no qualification claim here. |
|
||||
|
||||
Do not turn an untested native path into “unsupported” or “not applicable.”
|
||||
Research provider capabilities before choosing a response path. A text/reply-only
|
||||
channel can meet the standard with an understandable, correctly bound reply or
|
||||
an exact authenticated handoff; it need not imitate Slack's UI.
|
||||
|
||||
## Provider record template
|
||||
|
||||
Keep the record here or in a provider-specific document linked above. Use one row
|
||||
per inventory ID and relevant variant. Every row needs a state, including gaps;
|
||||
never drop difficult forms from the denominator.
|
||||
|
||||
Record metadata once per campaign:
|
||||
|
||||
- Accountable owner, date, source commit and dirty-tree fingerprint if relevant,
|
||||
provider/client surface, adapter/SDK versions, and redacted configuration.
|
||||
- Test scope: local serialization, real-provider local candidate, live-model
|
||||
behavior, or deployed verification. Name a deterministic fixture as such.
|
||||
- Source entry points: runtime input, composer/card, legacy payload, or canonical
|
||||
question set. List applicable variants and those not exercised.
|
||||
- Authorized isolated bot/channel and conversation references, reproducible
|
||||
setup, evidence location accessible to reviewers, and cleanup outcome. Keep
|
||||
private conversation links and credentials out of public repository records.
|
||||
|
||||
Use this row schema:
|
||||
|
||||
| Field | Required content |
|
||||
| --- | --- |
|
||||
| Inventory ID and variant | Q01–Q11 or a newly added ID, exact payload/source entry point, and required/optional/default/size variations. |
|
||||
| Provider capability and treatment | Native control, structured reply, or Paperclip handoff; official capability/limit reference and installed adapter behavior. Explain why this is the best feasible path. |
|
||||
| Canonical mapping and authority | How the response maps to the saved question/decision, actor and conversation, including validation and current authorization. A review-link click is navigation, not a response or grant. |
|
||||
| Functional status | Pass, fail, not run, or not applicable with reason; expected versus actual saved result, canonical continuation policy/count, artifacts, and delivery receipts. |
|
||||
| Experience grades | Separate ✓/✕ for good experience and best feasible experience, with a short rationale after inspecting unanswered, answering, and settled states. Missing inspection is not run. |
|
||||
| Evidence and history | Actual provider captures and link-click outcomes, durable-state correlation, failed attempts, repair/retest, and exact candidate for each attempt. |
|
||||
| Remaining coverage | Untested variants, boundary/negative-test coverage, provider constraints, and any live-model or deployment gap. Name the next required check. |
|
||||
|
||||
Prefer a browsable per-format gallery. Keep the conclusions and coverage gaps in
|
||||
this durable record or the PR even if its temporary preview expires. Sensitive
|
||||
evidence can stay in an access-controlled artifact with an accessible reference;
|
||||
do not copy secrets or private tenant data into the index. Reuse existing test
|
||||
harnesses and publication/continuation contracts instead of inventing a provider
|
||||
workflow engine to fill a matrix cell.
|
||||
|
||||
## Slack: October 24, 2026
|
||||
|
||||
Reference implementation and review:
|
||||
[PR #17255](https://github.com/paperclipai/paperclip/pull/17255).
|
||||
The local campaign's final implementation was recorded as
|
||||
`c3d458b2a06b660448940fd8cf33c5906f0058d5`; captures were collected during its
|
||||
implementation, with failures and repairs retained in the campaign gallery.
|
||||
This is a historical evidence summary, not a certification of later PR heads
|
||||
or staging. The PR verification section records later regression fixes/checks.
|
||||
|
||||
**Scope:** dedicated real Slack QA conversation, local server candidate, desktop
|
||||
Slack client, and a deterministic agent protocol fixture. Each core case had an
|
||||
actual unanswered/answered pair, saved result, one intended continuation, and a
|
||||
published final reply. Open controls and validation states were captured where
|
||||
applicable. The author inspected the gallery and graded each captured experience.
|
||||
The local gallery remains in the originating work chat; it is not a public or
|
||||
permanent hosted acceptance artifact. Reproduce affected cases when that private
|
||||
evidence is unavailable to the reviewer.
|
||||
|
||||
The table describes exercised paths, not every possible payload within a kind.
|
||||
The playbook defines the preferred behavior and broader variant requirements.
|
||||
|
||||
| ID | Path exercised in Slack | Observed functional result | Good / best feasible | Scope notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Q01 | Native multiline text modal | Pass | ✓ / ✓ | Full prompt/context, editable starting text, saved answer retained. |
|
||||
| Q02 | Direct choice buttons | Pass | ✓ / ✓ | Descriptions and recommendation visible; selected label retained. |
|
||||
| Q03 | Inline searchable single-select | Pass | ✓ / ✓ | Selected the thirteenth option by searching; saved label remained visible. |
|
||||
| Q04 | Checkboxes and explicit submit | Pass after repair | ✓ / ✓ | Empty submission feedback, two selected labels retained, and one continuation after fixing multi-selection authorization. Original failure retained. |
|
||||
| Q05 | Choice buttons plus **Other…** text modal | Pass | ✓ / ✓ | Both listed and custom answers exercised; descriptions and recommendation retained. |
|
||||
| Q06 | Native text modal with inline validation | Pass | ✓ / ✓ | Invalid pattern kept the draft open; corrected answer saved and continued. Not every numeric/length variant was tested live. |
|
||||
| Q07 | Accept/Reject buttons | Pass | ✓ / ✓ | Ordinary acceptance exercised. Governed and required-reason variants retain the canonical review path; not all were tested live. |
|
||||
| Q08 | Checklist with Save and Reject | Pass | ✓ / ✓ | Defaults and bounds visible; over-selection rejected, correction saved, exact selection retained. |
|
||||
| Q09 | Native per-item radio choices and reason fields | Pass | ✓ / ✓ | Required-reason validation and atomic submission exercised; decisions and reasons readable afterward. |
|
||||
| Q10 | Create task, multi-proposal selection/Create selected, and Dismiss | Pass | ✓ / ✓ | Reviewed created task title/description/parent independently; clicked review and created-task links to their correct destinations. |
|
||||
| Q11 | Authenticated **Review connection** handoff and native **Not now** | Pass after repair | ✓ / ✓ | Clicked the exact review destination and exercised decline without a grant; removed a spurious error caused by the passive link callback. No OAuth/access grant attempted. |
|
||||
|
||||
**Remaining scope:** these eleven cases do not establish all large/mixed-form
|
||||
boundaries, every terminal state, mobile behavior, other providers, live-model
|
||||
judgment, or deployed staging behavior. Deterministic regressions cover additional
|
||||
authorization, duplicate-response, and fallback cases. Later review identified
|
||||
oversized multi-select continuation and large custom-choice fallback defects;
|
||||
their failed-before tests, fixes, and final-head verification belong to the PR
|
||||
record and must not be hidden by this core-case scorecard.
|
||||
|
||||
**Cleanup:** restored the QA app's original callbacks, closed temporary callback
|
||||
and read-only preview tunnels, and removed temporary local credential copies.
|
||||
The read-only preview exercised navigation, not a connection permission grant.
|
||||
|
||||
### Lessons behind the quality bar
|
||||
|
||||
The PAP-701 investigation and this campaign exposed distinct failures:
|
||||
|
||||
1. A title plus **Respond** hid the actual question and context. Inventory
|
||||
question content and its answering path, not only interaction kinds.
|
||||
2. The first eleven-case gallery proved saved results returned to Slack, but
|
||||
only three cases answered natively and only one passed the complete UX
|
||||
review. Functional parity and “native” are both weaker than a good experience.
|
||||
3. Real multi-select submission saved correctly but failed continuation
|
||||
authorization. Inspect durable results and subsequent delivery alongside the
|
||||
screenshots; a settled card is not completion.
|
||||
4. The connection review link reached its destination while its callback also
|
||||
displayed a misleading error. Click every relevant handoff/result link and
|
||||
inspect both sides before grading it.
|
||||
5. Later boundary review found gaps beyond the core gallery. Keep core-case UX
|
||||
evidence separate from boundary coverage, retain failures, and remeasure
|
||||
changed paths on the candidate that will ship.
|
||||
|
||||
These are reusable acceptance requirements. The eleven Slack cases, particular
|
||||
option counts, fixture choice, and temporary tunnel setup are campaign details;
|
||||
future providers must research their own controls, limits, and delivery mechanism.
|
||||
@@ -3,7 +3,8 @@
|
||||
Audience: agents and engineers researching, implementing, testing, reviewing,
|
||||
and shipping Paperclip app connections.
|
||||
|
||||
Status: canonical end-to-end authoring guide for Apps v2 catalog connections.
|
||||
Status: canonical end-to-end authoring guide for Apps v2 tool connections and
|
||||
chat channel connectors.
|
||||
|
||||
Experimental voice connections use the same company-scoped channel endpoints,
|
||||
vault, task bindings, delivery ledger and publications, with `transport: voice`
|
||||
@@ -33,7 +34,12 @@ For chat and email setup, account linking, and ongoing configuration, also follo
|
||||
layout, credential instructions, provider handoffs, identity linking, optional
|
||||
message tests, and management pages, with examples for other providers.
|
||||
|
||||
Use it when Paperclip acts on an external system through a governed connection: a stored credential, a capability catalog, access profiles and policy rules, and audit. Inbound integrations, such as an external client acting on Paperclip, use gateway or webhook guidance instead.
|
||||
Use the tool-connection workflow when Paperclip acts on an external system
|
||||
through a governed credential and capability catalog. For conversations with
|
||||
agents in Slack or another messaging provider, also follow
|
||||
[Chat channel connectors](#chat-channel-connectors), including inbound messages,
|
||||
questions, responses, and delivery. Other inbound integrations, such as an
|
||||
external API client acting on Paperclip, use gateway or webhook guidance.
|
||||
|
||||
**A catalog entry is a convenience layer, not a prerequisite.** An operator can connect any
|
||||
standards-compliant remote HTTP MCP server from **Connect your own MCP server**
|
||||
@@ -196,6 +202,7 @@ read/write proof separately and never describe metadata discovery as live proof.
|
||||
- [Golden-path agent tutorial](#golden-path-agent-tutorial)
|
||||
- [Connection UX and user journeys](#connection-ux-and-user-journeys)
|
||||
- [Optional agent instructions](#optional-agent-instructions)
|
||||
- [Chat channel connectors](#chat-channel-connectors)
|
||||
- [Chat and email connector UX](./CHAT-CONNECTOR-UX.md)
|
||||
- [Production validation evidence](#step-9-align-with-production-validation)
|
||||
- [AppDefinition field reference](#appdefinition-field-reference)
|
||||
@@ -221,6 +228,238 @@ A complete connector proposal produces:
|
||||
denied/quarantined call when the method declares one, revoke, and audit
|
||||
evidence.
|
||||
|
||||
## Chat channel connectors
|
||||
|
||||
A tool connection gives an agent actions it can call. A chat channel connector
|
||||
makes Slack, Teams, Discord, Telegram, email, or another conversation surface a
|
||||
place where a person can conduct work with an agent. It must carry questions,
|
||||
decisions, follow-ups, and results through the conversation. Authentication,
|
||||
tool discovery, and a successful outbound message alone do not qualify it.
|
||||
A provider can offer both kinds of connection; do not assume that its tool
|
||||
connection also implements its conversation channel.
|
||||
|
||||
Reuse company-scoped endpoints, verified identity links, task/conversation
|
||||
bindings, durable interactions, publication queues, and response-delivery receipts.
|
||||
Keep provider payloads, UI limits, and callback protocols in their platform module;
|
||||
follow [chat channel platform boundaries](../../rules/chat-channel-platform-boundaries.mdc).
|
||||
Setup and account linking follow [Chat connector UX](./CHAT-CONNECTOR-UX.md).
|
||||
|
||||
### Cover every user-question form
|
||||
|
||||
For a new channel, build a capability matrix from the current shared interaction
|
||||
kinds, payload validators, and runtime/composer question entry points. For a new
|
||||
or changed form, update that matrix for every supported channel. Start with
|
||||
`ISSUE_THREAD_INTERACTION_KINDS` in `packages/shared/src/constants.ts` and the
|
||||
question contracts in `packages/shared/src/types/issue.ts` and
|
||||
`packages/shared/src/validators/issue.ts`; do not treat this list as frozen.
|
||||
|
||||
| Request family | Required coverage |
|
||||
| --- | --- |
|
||||
| `ask_user_questions` and runtime/composer input questions | Free text, single-select, multi-select, multiple questions, custom answers, and editable starting text. Include legacy payloads and canonical `paperclip.question_set.v1` forms. |
|
||||
| `request_confirmation` | The decision, supporting context, and accept/reject outcome. |
|
||||
| `request_checkbox_confirmation` | The options, selection constraints, and exact submitted selection. |
|
||||
| `request_item_verdicts` | Each item, its evidence/context, and individual decisions. |
|
||||
| `suggest_tasks` | The proposed tasks and the decision for each proposal. |
|
||||
| `connection_intent` | The requested connection/access, why it is needed, and the authorized setup or review path. |
|
||||
| Approval or runtime permission requests outside the interaction kinds | An explicit route to the existing authorized approval surface; a chat response must not create new approval authority. |
|
||||
|
||||
### Question experience inventory
|
||||
|
||||
The request-family table describes contracts; it is not a sufficient UX test
|
||||
inventory. Enumerate these eleven experiences separately because the same
|
||||
`ask_user_questions` kind can require very different controls. These IDs are
|
||||
shared with the [channel question coverage index](./CHAT-QUESTION-COVERAGE.md).
|
||||
Add rows when the contracts gain an experience this inventory does not cover.
|
||||
|
||||
| ID | Experience | Preferred interaction when the provider supports it |
|
||||
| --- | --- | --- |
|
||||
| Q01 | Free text | Native text entry with the full prompt, useful context, editable starting text, and multiline support where needed. |
|
||||
| Q02 | Short, closed single choice | Direct labeled buttons. Preserve option descriptions and recommendations; do not require a modal just to choose one item. |
|
||||
| Q03 | Long option single-select | An inline searchable picker where available; preserve complete labels and the selected value. |
|
||||
| Q04 | Multiple selection | Checkboxes or a multi-select with an explicit submit action and visible selection bounds. Selecting one item must not prematurely submit the form. |
|
||||
| Q05 | Choice plus custom answer | The appropriate choice controls plus an explicit **Other…** text-entry path. Test both a listed answer and a custom answer. |
|
||||
| Q06 | Validated text | Text entry with the expected format/example, field-level errors, and a retained editable draft after invalid input. Cover the contract's pattern, length, and numeric constraints. |
|
||||
| Q07 | Ordinary confirmation | Clear accept/reject controls with the decision and context visible. Governed actions and required reasons must retain their canonical review requirements. |
|
||||
| Q08 | Checklist confirmation | Labeled selections, descriptions, editable defaults, selection bounds, explicit save, and rejection where supported by the contract. |
|
||||
| Q09 | Per-item verdicts | A decision for each item, its evidence/context, conditional reasons, and submission using the canonical atomic or partial-resolution semantics. |
|
||||
| Q10 | Suggested tasks | Direct creation for one proposal; selection and **Create selected** for several; dismissal and a review/edit handoff. Show links to the actual created tasks afterward. |
|
||||
| Q11 | Connection request | Explain the requested access and purpose. Provide a usable link to the exact authenticated review and a native decline when supported. A link click must never grant access. |
|
||||
|
||||
The inventory is a minimum, not eleven happy-path fixtures standing in for all
|
||||
behavior. Record applicable variants under each ID:
|
||||
|
||||
- Runtime input, composer cards, legacy payloads, and canonical question sets;
|
||||
multiple questions, mixed forms, and repeated labels such as Yes/No in
|
||||
different questions.
|
||||
- Required/optional inputs, empty answers, editable defaults, descriptions,
|
||||
recommendations, Unicode, long labels/context, and custom-answer combinations.
|
||||
- Every control-selection threshold: just below, at, and above the provider's
|
||||
button, option, text, block, and modal limits. Test a fallback form through its
|
||||
response and continuation, not only the fallback card. Provider limits differ;
|
||||
research the current official documentation and installed SDK.
|
||||
- Accepted/rejected/dismissed outcomes, cancelled editing, expired or superseded
|
||||
requests, duplicate callbacks, revoked access, and recovery after publication
|
||||
or finalization failure. Cover document/attachment review and governed
|
||||
permissions separately wherever they affect the question's meaning.
|
||||
|
||||
### Choose and record the best available response path
|
||||
|
||||
Keep a per-provider matrix linked from the
|
||||
[channel question coverage index](./CHAT-QUESTION-COVERAGE.md). For every ID and
|
||||
applicable variant, record native controls, a structured reply flow, or an exact
|
||||
Paperclip handoff; the provider capability/limit supporting that choice; the
|
||||
canonical answer mapping; and the evidence and remaining gaps. Generic adapter
|
||||
flags such as `cards` or `modals` are discovery hints, not per-form qualification.
|
||||
A provider lacking a native control still needs a usable response path. Mark
|
||||
**not applicable** only when the underlying workflow cannot occur, with a reason.
|
||||
Do not use it to hide an unimplemented renderer or unavailable test account.
|
||||
|
||||
Use the richest practical interaction that preserves meaning, authorization,
|
||||
and accessibility. A functional browser fallback is not the best experience
|
||||
when a supported native control removes unnecessary work. A richer-looking
|
||||
control is also wrong if it drops options, changes validation, or implies new
|
||||
authority. Explain unavoidable handoffs before the click and preserve the
|
||||
originating conversation through resolution. Do not force all providers to
|
||||
Slack's controls or to the least capable provider's interface.
|
||||
|
||||
**Every applicable form needs a usable path from the originating conversation.**
|
||||
Prefer native controls or a reply flow when the channel can preserve the form's
|
||||
meaning, validation, and authorization. Otherwise publish an explicit link to
|
||||
the exact Paperclip question or review surface. A fallback is supported behavior;
|
||||
silently omitting the request is a defect. Record which path each row uses.
|
||||
|
||||
- Show the actual question and relevant context, including descriptions, help,
|
||||
option labels/details, recommendations, and required/optional status. A title
|
||||
such as “Identify the failed runner change” plus **Respond** is insufficient
|
||||
when the payload contains a more specific question.
|
||||
- Prefer direct buttons for a few closed choices, a searchable selector for a
|
||||
longer list, and a checklist with an explicit submit action for multiple choices.
|
||||
Use a native modal for free text, custom answers, and per-item verdicts with
|
||||
reasons. Show errors next to the relevant input without discarding the draft.
|
||||
Task suggestions must support selecting which tasks to create and dismissing
|
||||
them. Connection requests may offer a native decline; granting access stays
|
||||
on the authenticated review surface. Keep the saved labels, answers, verdicts,
|
||||
and reasons readable after the controls disappear.
|
||||
- Preserve text, numeric, pattern, length, and selection validation. Starting
|
||||
text is editable draft content, never an implicit answer. Preserve repeated
|
||||
choices within separate questions. If native controls cannot do this, use the
|
||||
explicit Paperclip handoff rather than dropping fields or changing semantics.
|
||||
- Respect provider message, block, option, and modal limits. For oversized forms,
|
||||
show a useful question/context excerpt, state that the full request is in
|
||||
Paperclip, and provide its link. Do not silently truncate a decision or label
|
||||
a partial form as complete.
|
||||
- Apply the existing safe external-text projection and audience rules to prompts,
|
||||
help, drafts, links, and results. Keep secrets and restricted content on the
|
||||
authorized Paperclip surface; explain the handoff without exposing that content.
|
||||
- Bind a response to the persisted question, conversation, and verified actor.
|
||||
Recheck current access, resolver audience, target freshness, and endpoint
|
||||
authority. Use the same authoritative resolution service as Paperclip. A
|
||||
channel callback or ordinary reply cannot bypass a governed action's approval.
|
||||
- Persist the answer once and use the existing continuation policy and delivery
|
||||
receipts. Duplicate clicks, provider retries, and recovery must not create
|
||||
duplicate decisions or continuations. Preserve task and conversation context.
|
||||
A Paperclip fallback response must retain its authenticated Board receipt;
|
||||
never fabricate a provider callback. Follow each review kind's existing wake
|
||||
or delivery receipt when routing its later result back to the conversation.
|
||||
A settled card alone does not prove that the resumed task delivered its result.
|
||||
- Reflect accepted, rejected, expired, and superseded outcomes in the original
|
||||
channel card or an explicit follow-up. Honor the canonical meaning of dismissal
|
||||
or deferral; it is not an answer or permission to resume. Include automatic
|
||||
reconciliation and recovery paths, so resolved requests do not remain actionable.
|
||||
|
||||
Apply this contract when adding a channel and whenever a question, card, composer,
|
||||
or runtime bridge gains a new form or field. A working Paperclip card does not
|
||||
establish channel support. The
|
||||
[interaction-authoring skill](../../.agents/skills/create-issue-interaction-ui/SKILL.md)
|
||||
must include channel publication, response, and settlement in the same change.
|
||||
|
||||
### Verify and grade each experience
|
||||
|
||||
Declare the inventory and finish line before implementation. For each affected
|
||||
format: implement the best supported interaction, exercise it end to end in the
|
||||
actual provider, capture evidence, inspect that evidence yourself, and repair
|
||||
any remaining UX gap. Do not wait for the requester to ask separately for real
|
||||
provider testing, screenshots, or a judgment about quality.
|
||||
|
||||
Start locally with the smallest failing case and exercise the installed SDK,
|
||||
serialized provider payloads, authenticated inbound responses, persisted answers,
|
||||
and the existing continuation path. Work through one format at a time, using
|
||||
focused regressions before the next real-provider attempt. An authorized local
|
||||
candidate connected to a dedicated test bot/channel can provide this proof
|
||||
before staging deployment. Keep provider webhook ingress, internal API access,
|
||||
and human-facing board URLs separate. Use only the necessary authorized callback
|
||||
or preview exposure; restore provider configuration and remove temporary access
|
||||
at the end. Infrastructure failures remain named blockers, not product passes.
|
||||
|
||||
For a new channel, qualify every applicable matrix row. For a behavior change,
|
||||
repeat affected rows and shared paths, including native and Paperclip-fallback
|
||||
journeys:
|
||||
|
||||
request → visible question/context → response → persisted resolution → intended
|
||||
continuation → settled card and result in the originating conversation.
|
||||
|
||||
Record independent judgments for each row:
|
||||
|
||||
| Judgment | Pass standard |
|
||||
| --- | --- |
|
||||
| Functional result | The exact answer/decision is saved, authorization is preserved, the canonical continuation policy runs without duplicate work, and the intended result is accessible in the originating conversation. |
|
||||
| Good experience (✓/✕) | The person can understand the question, answer it, recover from an error, and read the saved outcome without guessing. Inspect unanswered, answering, and settled states. |
|
||||
| Best feasible experience (✓/✕) | The chosen controls use the provider's available capabilities well. There is no avoidable modal, browser hop, lost context, or unnecessary interaction. Any handoff has a concrete capability, validation, or authorization reason. |
|
||||
|
||||
Use **not run** for missing evidence and explain it; it is not a check mark.
|
||||
A native modal is appropriate for substantial text or a multi-item review but
|
||||
can be needless friction for a simple choice. A settled “Answered” badge that
|
||||
hides the saved choice or text fails the experience check. Publish only the
|
||||
safe, authorized answer projection, and make its audience clear at entry;
|
||||
sensitive review content may require a clearly explained authenticated handoff.
|
||||
|
||||
### Retain inspectable per-format evidence
|
||||
|
||||
Produce an HTML gallery or equivalent browsable report with one section per
|
||||
format and variant. Capture actual provider screens, not reconstructed cards:
|
||||
|
||||
- **Unanswered:** the full question/context and available controls.
|
||||
- **Answering:** the open picker/modal or selected values; include invalid input
|
||||
and the preserved draft for validation cases. For a one-click button, the
|
||||
before/after pair is sufficient.
|
||||
- **Answered:** the settled card with the permitted saved answer, labels,
|
||||
decisions, and reasons, plus the resumed result or review receipt.
|
||||
- **Handoffs and artifacts:** actually click review and created-result links,
|
||||
confirm the correct authenticated destination, and inspect the resulting
|
||||
task/artifact. A valid-looking URL or successful API response is insufficient.
|
||||
|
||||
Inspect each captured state yourself. Put functional status, both UX grades,
|
||||
a short rationale, and any repair/retest next to the evidence. These are author
|
||||
acceptance artifacts for channel qualification, not a blanket screenshot
|
||||
request for unrelated UI PRs. Keep material failed attempts and their causes
|
||||
alongside successful retries; a retry must not erase the failure history.
|
||||
|
||||
Correlate screenshots with the canonical saved response, answer actor/authority,
|
||||
continuation count, publication/delivery receipts, and actual created artifacts.
|
||||
A visually settled multi-select can still fail continuation authorization; a
|
||||
review link can navigate correctly while also firing an erroneous answer
|
||||
callback. Test these complete paths. Deterministic coverage must also include
|
||||
repeated callbacks, stale requests, revoked membership/identity/access, mixed
|
||||
forms, and provider-limit fallback boundaries.
|
||||
|
||||
Record the candidate revision, dirty-tree fingerprint if applicable, date,
|
||||
provider/adapter configuration, source entry point, test conversation, and
|
||||
reproduction steps without secrets. State whether the agent was a deterministic
|
||||
protocol fixture or a live model. Keep local simulations, real-provider
|
||||
acceptance, live-model behavior, and deployed verification separate. Storybook,
|
||||
substituted HTTP, a green run badge, or an agent's success claim cannot establish
|
||||
real-channel acceptance. Proof for one channel, one client surface, or eleven
|
||||
core cases does not qualify every provider or boundary variant.
|
||||
|
||||
Link accessible redacted evidence from the provider record and PR; retain the
|
||||
requirements and conclusions in the repository even if a temporary preview
|
||||
expires. Update the [coverage index](./CHAT-QUESTION-COVERAGE.md) with precise
|
||||
scope and gaps. When later changes affect rendering, authorization, or delivery,
|
||||
rerun the affected cases on that candidate; do not relabel old screenshots as
|
||||
current proof. A format is complete only when its applicable functional and
|
||||
experience checks pass. Report provider limitations and outstanding acceptance
|
||||
explicitly; missing evidence leaves the affected qualification pending.
|
||||
|
||||
## Optional agent instructions
|
||||
|
||||
Some connections need standing guidance: tools tell an agent what it *can* do,
|
||||
@@ -1456,6 +1695,11 @@ A catalog connection is ready only when every applicable item is true:
|
||||
- [ ] Official docs and live metadata agree on the endpoint and auth flow.
|
||||
- [ ] Self-serve/provider-approval classification is documented.
|
||||
- [ ] Every exposed method has completed the full live lifecycle.
|
||||
- [ ] Chat channels have an indexed per-format capability/qualification matrix,
|
||||
a justified best-available response path, and inspected real-provider
|
||||
evidence for affected conversation loops. Functional, answering, and
|
||||
settled-state UX checks pass; remaining gaps are explicit under
|
||||
[Chat channel connectors](#chat-channel-connectors).
|
||||
- [ ] The default path asks only for required configuration.
|
||||
- [ ] Optional own-OAuth and expert fields are folded under Advanced.
|
||||
- [ ] Scopes are explicit and contained; caller widening is rejected.
|
||||
@@ -1735,6 +1979,7 @@ connector proposal or PR. No separate private validation issue is required.
|
||||
| Allowed execution | A narrow read succeeds through the gateway; writes follow the effective policy. | Tool, actor, decision, redacted result, and correlated call/audit record. Use a disposable resource for an authorized live write; otherwise mark write execution untested. |
|
||||
| Denied execution | Ungranted actors, another company, disallowed resources, revoked connections, and declared blocked/quarantined actions cannot execute. | Expected denial and reason code, with the automated or live test that exercised the boundary. Cover listing where the policy requires tools to be hidden. |
|
||||
| Runtime delivery | An actual agent/run-scoped gateway call succeeds when runtime logic changes. Chat/email also routes an incoming message and its reply to the same task. | Redacted task/conversation and call correlation; identify live versus simulated events. |
|
||||
| Chat questions and decisions | Every applicable question experience has the best feasible controls or a justified handoff, saves the answer, and delivers the intended continuation result. | [Indexed per-format qualification](./CHAT-QUESTION-COVERAGE.md), actual unanswered/answering/answered captures, separate functional and UX grades, saved-response/delivery evidence, and retained failures. |
|
||||
| Refresh and recovery | Catalog refresh, token refresh/reconnect, and recoverable failures preserve the correct identity and policy. | Before/after outcome, redacted error code, and successful retry; no duplicated connection. |
|
||||
| Revoke and reconnect | Revocation blocks subsequent execution; supported reconnect reuses the intended identity/history. | Removal or denial evidence followed by reconnect result. |
|
||||
| Activity and secret handling | Activity explains what occurred and why; stored responses, logs, and artifacts contain no credential values. | Actor, run/issue context, resource, decision, reason code, outcome, plus redaction-check result. |
|
||||
|
||||
@@ -8,6 +8,10 @@ agent tutorial from provider research and protocol classification through
|
||||
manifest generation, branding, secrets, deterministic tests, real-account
|
||||
proof, and PR submission.
|
||||
|
||||
Chat and email channels: [conversation-channel contract](./CONNECTOR-PLAYBOOK.md#chat-channel-connectors),
|
||||
[question inventory and quality bar](./CONNECTOR-PLAYBOOK.md#question-experience-inventory),
|
||||
and [per-provider question coverage index](./CHAT-QUESTION-COVERAGE.md).
|
||||
|
||||
Runtime authentication: [AI Connections](./AI-CONNECTIONS.md).
|
||||
|
||||
Long-term memory: [Experimental memory connectors](./MEMORY.md).
|
||||
|
||||
@@ -620,4 +620,21 @@ Evidence: `source-only` — [answer delivery](../../server/src/services/question
|
||||
|
||||
Verification: `not-run` — Answer in actual configured channel -> one persisted result/useful originating reply; wrong/unlinked/expired sender denied. Overlap two conversations and verify no cross-delivery/replay.
|
||||
|
||||
Slack presentation uses opaque native response tokens for text, choices,
|
||||
multiple selections, custom answers, validated text, ordinary confirmations,
|
||||
checklists, item verdicts, suggested tasks, and connection decline. Permission
|
||||
grants, governed approvals, and forms that exceed native limits retain authenticated
|
||||
Paperclip review. Link clicks are passive and confer no decision authority.
|
||||
Current company, audience, identity, connection, and governed-action checks apply.
|
||||
Selections map back to the canonical response through the existing transaction
|
||||
and callback receipt. Terminal cards retain the submitted answer only in the
|
||||
original authorized conversation. Native multi-choice continuation validates the
|
||||
exact saved selection mapping; a native answer token cannot be substituted by a
|
||||
Board receipt. Later review replies follow the
|
||||
[existing wake and connection-delivery receipts](../../server/src/services/chat-interaction-continuation.ts).
|
||||
For automation-origin connection requests, validated receipts can recover the
|
||||
conversation from the published prompt. Local Slack proof covers eleven formats
|
||||
with a deterministic protocol fixture; production agent authority and other
|
||||
provider paths retain their independent verification requirements.
|
||||
|
||||
Gaps: Slack/Discord/Telegram/email/iMessage and runtime paths independently not-run until driven.
|
||||
@@ -187,9 +187,26 @@ Automated: [chat question forms](../../server/src/services/chat-question-forms.t
|
||||
and [interaction publications](../../server/src/__tests__/chat-interaction-publications.test.ts)
|
||||
cover serialization and delivery logic with fixtures.
|
||||
|
||||
Free-text and multi-question cards must show their title, question prompts, help
|
||||
text, and relevant context beside Respond. Settled cards must not expose saved
|
||||
free-text answers. The interaction publication database suite covers both states.
|
||||
Free-text and multi-question cards show their title, prompts, help, and context.
|
||||
Slack modals retain instructions and editable starting text. Closed single-choice
|
||||
questions use buttons for up to five options and an inline searchable selector
|
||||
for six to 100. Multiple choices use checkboxes (up to ten) or a multiple selector,
|
||||
then explicit Submit. Custom choices combine direct buttons with an Other modal;
|
||||
pattern errors appear beside the native input. Ordinary confirmations use buttons;
|
||||
checklists preserve defaults and bounds; item verdicts use radio choices and reasons;
|
||||
suggested tasks use Create, selection, and Dismiss. Connection requests offer
|
||||
Review connection and native Not now, without granting access from Slack.
|
||||
|
||||
Governed approvals, unsupported mixed forms, attachments, and oversized requests
|
||||
retain the full Paperclip review path. Link clicks navigate without attempting an
|
||||
answer. Terminal edits preserve the submitted response in the same authorized
|
||||
conversation and remove stale controls. Inspect the saved interaction, exactly
|
||||
one continuation, and useful reply independently. See the
|
||||
[continuation regressions](../../server/src/services/chat-interaction-continuation.test.ts)
|
||||
and [Slack card tests](../../server/src/services/chat-channels/slack/cards.test.ts).
|
||||
Local real-Slack proof for eleven formats, including native validation and public
|
||||
link navigation, is recorded with PR #17255. It uses a deterministic protocol
|
||||
fixture and does not establish deployed staging or model reliability.
|
||||
|
||||
Manual: use a disposable provider conversation, answer as the linked sender,
|
||||
and check the task's persisted result and one continuation. Repeat from an
|
||||
|
||||
@@ -409,6 +409,14 @@ export interface SafeExternalChatCard {
|
||||
title: string;
|
||||
body?: string;
|
||||
actions?: SafeExternalChatCardAction[];
|
||||
/** A compact choice whose opaque option tokens use the normal action ledger. */
|
||||
selection?: {
|
||||
actionId: string;
|
||||
label: string;
|
||||
multiple?: boolean;
|
||||
submitLabel?: string;
|
||||
options: Array<{ actionId: string; label: string; description?: string; selected?: boolean }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SafeChatPublicationPayload {
|
||||
|
||||
@@ -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