diff --git a/.agents/skills/create-issue-interaction-ui/SKILL.md b/.agents/skills/create-issue-interaction-ui/SKILL.md index 984a81ad612..645454d1d5a 100644 --- a/.agents/skills/create-issue-interaction-ui/SKILL.md +++ b/.agents/skills/create-issue-interaction-ui/SKILL.md @@ -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 (`:::`) 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. diff --git a/doc/connections/CHAT-CONNECTOR-UX.md b/doc/connections/CHAT-CONNECTOR-UX.md index 167ae968863..c83814714e0 100644 --- a/doc/connections/CHAT-CONNECTOR-UX.md +++ b/doc/connections/CHAT-CONNECTOR-UX.md @@ -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 diff --git a/doc/connections/CHAT-QUESTION-COVERAGE.md b/doc/connections/CHAT-QUESTION-COVERAGE.md new file mode 100644 index 00000000000..fac2d514346 --- /dev/null +++ b/doc/connections/CHAT-QUESTION-COVERAGE.md @@ -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. diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 8f4b2566b33..9343d1441c0 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -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. | diff --git a/doc/connections/README.md b/doc/connections/README.md index 017c332e3b2..ab5f12475cf 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -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). diff --git a/maps/agent-access-map/questions-and-approvals.md b/maps/agent-access-map/questions-and-approvals.md index 9e38f6ac818..b6874e75f8d 100644 --- a/maps/agent-access-map/questions-and-approvals.md +++ b/maps/agent-access-map/questions-and-approvals.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. diff --git a/maps/feature-map/questions-and-approvals.md b/maps/feature-map/questions-and-approvals.md index 01cf138fab5..62a3d6e178c 100644 --- a/maps/feature-map/questions-and-approvals.md +++ b/maps/feature-map/questions-and-approvals.md @@ -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 diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index e83627fbadc..4a2c5b0a211 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -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 { diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index c2ef1b9a34d..4fd186e6271 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -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[1], { agentId: fixture.assignedAgentId }); + await service.processPendingPublications(); + const rows = () => db.select().from(chatPublications).where(and(eq(chatPublications.endpointId, endpoint.id), eq(sql`${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", diff --git a/server/src/__tests__/chat-interaction-publications.test.ts b/server/src/__tests__/chat-interaction-publications.test.ts index d99bc1d13dc..f5a77bf08d6 100644 --- a/server/src/__tests__/chat-interaction-publications.test.ts +++ b/server/src/__tests__/chat-interaction-publications.test.ts @@ -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[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[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); }); diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index c47eeef0c98..33b03986b83 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -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); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 97ef4f5be2f..3532bc7d663 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -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"); diff --git a/server/src/__tests__/satisfied-connection-intents.test.ts b/server/src/__tests__/satisfied-connection-intents.test.ts index 36bfab8c9d9..36eb4555df3 100644 --- a/server/src/__tests__/satisfied-connection-intents.test.ts +++ b/server/src/__tests__/satisfied-connection-intents.test.ts @@ -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 }); diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 6963c785d7f..e53608b49de 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -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, diff --git a/server/src/services/chat-channels/interactions/callbacks.ts b/server/src/services/chat-channels/interactions/callbacks.ts index 93c1e5f9f07..e9580a762b0 100644 --- a/server/src/services/chat-channels/interactions/callbacks.ts +++ b/server/src/services/chat-channels/interactions/callbacks.ts @@ -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; 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["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(); } diff --git a/server/src/services/chat-channels/telegram/confirmation-actions.ts b/server/src/services/chat-channels/interactions/confirmation-actions.ts similarity index 77% rename from server/src/services/chat-channels/telegram/confirmation-actions.ts rename to server/src/services/chat-channels/interactions/confirmation-actions.ts index 8e5c9971b8f..caaef9ab99f 100644 --- a/server/src/services/chat-channels/telegram/confirmation-actions.ts +++ b/server/src/services/chat-channels/interactions/confirmation-actions.ts @@ -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; runtimeContext: RuntimeContext; @@ -49,21 +52,22 @@ type TelegramConfirmationInput = { deny: (known?: { conversationId?: string | null; principalId?: string | null }) => Promise; 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; isExternalActionAuthorizationChange: (error: unknown) => boolean; scheduleMessageProcessing: (task: () => Promise) => void; processPendingPublications: () => Promise; confirmationResolutionPersistBarrier?: () => Promise; }; -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 }; } diff --git a/server/src/services/chat-channels/publication-replacement.ts b/server/src/services/chat-channels/publication-replacement.ts index 626161201a2..b3bf6e08143 100644 --- a/server/src/services/chat-channels/publication-replacement.ts +++ b/server/src/services/chat-channels/publication-replacement.ts @@ -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} )`, diff --git a/server/src/services/chat-channels/publications/transport.ts b/server/src/services/chat-channels/publications/transport.ts index e375fe805ed..1480939a7ab 100644 --- a/server/src/services/chat-channels/publications/transport.ts +++ b/server/src/services/chat-channels/publications/transport.ts @@ -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)); diff --git a/server/src/services/chat-channels/slack/adapter.ts b/server/src/services/chat-channels/slack/adapter.ts index 6f3d691a8bd..9e76796544b 100644 --- a/server/src/services/chat-channels/slack/adapter.ts +++ b/server/src/services/chat-channels/slack/adapter.ts @@ -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 { + 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 { + 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. diff --git a/server/src/services/chat-channels/slack/cards.test.ts b/server/src/services/chat-channels/slack/cards.test.ts new file mode 100644 index 00000000000..b379b4982b3 --- /dev/null +++ b/server/src/services/chat-channels/slack/cards.test.ts @@ -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[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[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[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); +}); diff --git a/server/src/services/chat-channels/slack/cards.ts b/server/src/services/chat-channels/slack/cards.ts new file mode 100644 index 00000000000..9a2b98a6e84 --- /dev/null +++ b/server/src/services/chat-channels/slack/cards.ts @@ -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["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): 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 | null => + value && typeof value === "object" && !Array.isArray(value) + ? value as Record : 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[]; +} diff --git a/server/src/services/chat-channels/slack/question-forms.test.ts b/server/src/services/chat-channels/slack/question-forms.test.ts new file mode 100644 index 00000000000..ace6ce66abe --- /dev/null +++ b/server/src/services/chat-channels/slack/question-forms.test.ts @@ -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(); + } + }); +}); diff --git a/server/src/services/chat-channels/slack/question-forms.ts b/server/src/services/chat-channels/slack/question-forms.ts new file mode 100644 index 00000000000..bd074f61cc6 --- /dev/null +++ b/server/src/services/chat-channels/slack/question-forms.ts @@ -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.", + }, + }; +} diff --git a/server/src/services/chat-interaction-arbitration.ts b/server/src/services/chat-interaction-arbitration.ts index dbb98715828..98b709e15a5 100644 --- a/server/src/services/chat-interaction-arbitration.ts +++ b/server/src/services/chat-interaction-arbitration.ts @@ -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 diff --git a/server/src/services/chat-interaction-continuation.test.ts b/server/src/services/chat-interaction-continuation.test.ts new file mode 100644 index 00000000000..28da03b155e --- /dev/null +++ b/server/src/services/chat-interaction-continuation.test.ts @@ -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> | null = null; + let db: ReturnType; + 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([]); + }); + +}); diff --git a/server/src/services/chat-interaction-continuation.ts b/server/src/services/chat-interaction-continuation.ts new file mode 100644 index 00000000000..9c827712e2c --- /dev/null +++ b/server/src/services/chat-interaction-continuation.ts @@ -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 { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record : {}; +} + +// 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 { + 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; +} diff --git a/server/src/services/chat-interaction-forms.test.ts b/server/src/services/chat-interaction-forms.test.ts new file mode 100644 index 00000000000..41485c08f88 --- /dev/null +++ b/server/src/services/chat-interaction-forms.test.ts @@ -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(); + }); +}); diff --git a/server/src/services/chat-interaction-forms.ts b/server/src/services/chat-interaction-forms.ts new file mode 100644 index 00000000000..3ec93b814f0 --- /dev/null +++ b/server/src/services/chat-interaction-forms.ts @@ -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 = {}; + 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 }; +} diff --git a/server/src/services/chat-interaction-presentation.ts b/server/src/services/chat-interaction-presentation.ts new file mode 100644 index 00000000000..b3749e17eff --- /dev/null +++ b/server/src/services/chat-interaction-presentation.ts @@ -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"); +} diff --git a/server/src/services/chat-interaction-publications.ts b/server/src/services/chat-interaction-publications.ts index 79018caf4fd..72b1779e68d 100644 --- a/server/src/services/chat-interaction-publications.ts +++ b/server/src/services/chat-interaction-publications.ts @@ -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)[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 = []; 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" && !( diff --git a/server/src/services/chat-publication-projection.ts b/server/src/services/chat-publication-projection.ts index eee29362226..c51f0203fe6 100644 --- a/server/src/services/chat-publication-projection.ts +++ b/server/src/services/chat-publication-projection.ts @@ -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 } : {}), }, }; } diff --git a/server/src/services/chat-question-forms.test.ts b/server/src/services/chat-question-forms.test.ts index d12610b951b..42dd5ae5524 100644 --- a/server/src/services/chat-question-forms.test.ts +++ b/server/src/services/chat-question-forms.test.ts @@ -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, diff --git a/server/src/services/chat-question-forms.ts b/server/src/services/chat-question-forms.ts index 715084693f0..d4093072cbe 100644 --- a/server/src/services/chat-question-forms.ts +++ b/server/src/services/chat-question-forms.ts @@ -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 { @@ -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 = {}; 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] ?? ""; diff --git a/server/src/services/chat-run-publications.ts b/server/src/services/chat-run-publications.ts index 74eb2ca3901..25ac411e74b 100644 --- a/server/src/services/chat-run-publications.ts +++ b/server/src/services/chat-run-publications.ts @@ -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`( + ${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`( ${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 ( diff --git a/server/src/services/chat-sdk-runtime.ts b/server/src/services/chat-sdk-runtime.ts index 1adb53bf2dd..cffdd09f5e4 100644 --- a/server/src/services/chat-sdk-runtime.ts +++ b/server/src/services/chat-sdk-runtime.ts @@ -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 { 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) } : {}), diff --git a/server/src/services/chat-slack-native-modal.test.ts b/server/src/services/chat-slack-native-modal.test.ts index 80605c5dbce..b672d0001ef 100644 --- a/server/src/services/chat-slack-native-modal.test.ts +++ b/server/src/services/chat-slack-native-modal.test.ts @@ -31,7 +31,7 @@ const botUserId = "U-PAPERCLIP-BOT"; type SubmitCallback = NonNullable; type SubmitEvent = Parameters[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 = []; 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] = { diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 442bf0155da..a6d07331c0c 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -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, ); } diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index eec17111280..25e0de11276 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -194,7 +194,8 @@ function record(value: unknown): Record { 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( diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 4e25c890615..9a4ed571a5b 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -197,7 +197,7 @@ export type IssueThreadInteractionServiceOptions = { }; type DbTransaction = Parameters[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; @@ -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 : 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; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b1cbdaa2fdb..7f3f78e9936 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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 }) diff --git a/server/src/services/native-runtime/external-chat-question-response.ts b/server/src/services/native-runtime/external-chat-question-response.ts index df45788684a..23875f181ec 100644 --- a/server/src/services/native-runtime/external-chat-question-response.ts +++ b/server/src/services/native-runtime/external-chat-question-response.ts @@ -75,6 +75,23 @@ const ids = (value: unknown): string[] => ? value.filter((id): id is string => typeof id === "string") : []; +function completedMultiChoiceMatches(question: Record, answer: Record, payload: Record): 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: { diff --git a/server/src/services/native-runtime/external-chat-wait.integration.test.ts b/server/src/services/native-runtime/external-chat-wait.integration.test.ts index 1bbdd1380a3..b0ee8e0c5f6 100644 --- a/server/src/services/native-runtime/external-chat-wait.integration.test.ts +++ b/server/src/services/native-runtime/external-chat-wait.integration.test.ts @@ -467,7 +467,7 @@ describe("native external-chat response wait", () => { fixture: Awaited>; gate: Awaited>; }, - 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> - | Awaited>, + | Awaited>, ) { expect( await attestReviewedExternalChatRun({ @@ -1240,13 +1262,13 @@ describe("native external-chat response wait", () => { check: (input: { fixture: | Awaited> - | Awaited>; + | Awaited>; invoke: () => Promise; }) => Promise, ) { 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( diff --git a/server/src/services/satisfied-connection-intents.ts b/server/src/services/satisfied-connection-intents.ts index f6a3eb39ef4..61b7bb21783 100644 --- a/server/src/services/satisfied-connection-intents.ts +++ b/server/src/services/satisfied-connection-intents.ts @@ -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);