fix(interactions): authorize resolvers consistently (#11376)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Issue interactions give agents and people a structured decision record. > - Resolver routes used different authorization rules. > - Some routes blocked valid agents, including task watchdogs with normal issue access. > - The API did not show who could resolve a pending interaction. > - This pull request gives every interaction kind one resolver policy evaluator. > - The benefit is a clear decision path with consistent governance and company isolation. ## Linked Issues or Issue Description Fixes: #8087 Refs: #7403 Related PR: #11082 proposes board-only confirmation rules. This change keeps human-only review as an explicit policy. **What happened?** Agents could create issue interactions. Some resolver routes still required board access. This left valid agent confirmations pending. Task watchdogs could see the same problem without board identity. **Expected behavior** Every interaction kind must use one resolver policy contract. The contract must support `anyone`, `not_creator`, and `human_only`. It must also apply all normal governance controls. **Steps to reproduce** 1. Create a `request_confirmation` interaction as an agent. 2. Resolve it with another authorized agent. 3. Observe the board-only denial. **Paperclip version or commit** The problem exists on `master` before this change. **Deployment mode** Local development with `pnpm dev`. ## What Changed - Add canonical policies for `anyone`, `not_creator`, and `human_only`. - Use one server evaluator for every interaction kind. - Apply named addressees, company limits, review rules, and task watchdog scope. - Charge cross-issue resolutions to the existing per-run action limit. - Return the effective resolver audience in attention and interaction data. - Show the audience, governance choices, and denial reasons in the board UI. - Add telemetry, API documents, product documents, and regression fixtures. - Add migration provenance for safe legacy behavior. - Make migration `0218` safe for complete replays and partial prior runs. ## Product Rules - An interaction records a response. It does not grant authority for the next action. - `anyone` lets any authorized issue participant respond. - `not_creator` requires a responder other than the interaction creator. - `human_only` requires an authorized person. - A named addressee, company policy, or governed action can narrow the audience. - These controls cannot widen the audience. - A task watchdog uses the same rules as an ordinary agent. - A task watchdog does not receive board authority. - An agent resolution on another issue uses the shared cross-issue action limit. - Legacy pending interactions keep their earlier restrictions. - The UI shows the effective audience and a permanent denial reason. ## Verification - `pnpm --filter @paperclipai/db check:migrations` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run packages/db/src/issue-thread-interaction-resolver-policy-migration.test.ts` - The focused PostgreSQL test applies migration `0218` twice. - The test also completes a partial prior run and preserves existing provenance. - The latest GitHub head has 29 successful checks. - The opt-in Storybook visual check skipped as expected. - Greptile reports 5/5 with no open comments. ## Risks - New interaction writes use `anyone` by default. - Callers must select `not_creator` or `human_only` when they need stricter review. - Legacy pending interactions keep the old creator and human restrictions. - Migration `0218` fills only missing provenance fields during recovery. - Cross-issue resolutions can reach the existing action limit. - The shared evaluator affects every interaction kind. - Route, service, database, shared contract, and UI tests cover these rules. > This work matches the Agent Reviews and Approvals direction in `ROADMAP.md`. It does not duplicate a planned item. ## Model Used OpenAI Codex, GPT-5. The runtime does not expose the exact deployment ID or context window. The agent used reasoning, repository tools, shell commands, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked public issues or described the issue with the required labels - [x] I have not referenced internal Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented the risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open comments - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d6acb48551
commit
10d0555189
|
|
@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
|
|||
| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
|
||||
| Communication | Tasks + comments only (no separate chat system) |
|
||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree and resolve only eligible task-level plan confirmations; it is not board authority, active-run output monitoring, or general liveness recovery. |
|
||||
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
|
||||
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
|
||||
|
|
@ -584,10 +584,11 @@ may wake the target assignee, including an explicit `resume: true` comment on a
|
|||
the normal agent rewake throttle; comment presentation cannot give it human
|
||||
wake privileges. Agent issue comments and updates require a persisted heartbeat
|
||||
run bound to the authenticated agent and company; missing, invalid, or mismatched
|
||||
run context fails closed before mutation. A run may attempt at most 20 cross-issue comments or issue
|
||||
updates across the shared counter. The server records each attempt with its
|
||||
source issue, target issue, run, count, and rollout mode, and fails closed with
|
||||
the cap in the error once enforcement is active. Assignee self-comments do not
|
||||
run context fails closed before mutation. A run may attempt at most 20 cross-issue comments, issue
|
||||
updates, or issue-thread interaction resolutions across one shared counter. The
|
||||
server records each attempt with its source issue, target issue, run, count, and
|
||||
rollout mode, and fails closed with the cap in the error once enforcement is
|
||||
active. Writes to the run's own source issue are not counted. Assignee self-comments do not
|
||||
wake the assignee, and a non-assignee comment cannot mint a mention grant.
|
||||
|
||||
Agent-authored issue comments persist the responsible user derived from the
|
||||
|
|
@ -660,6 +661,66 @@ approval is created. The legacy fields `protectedAgent.requiresApproval` and
|
|||
aliases for the same hard block, but API denial copy must describe the block and
|
||||
administrator remediation rather than promising a nonexistent approval step.
|
||||
|
||||
### 9.8.1 Issue-thread interaction resolver contract
|
||||
|
||||
Issue-thread interactions are coordination records, not grants of authority. Every
|
||||
interaction kind defaults to resolver policy `anyone` when the create request omits
|
||||
`resolverPolicy`. Restrictions are opt-in.
|
||||
|
||||
Canonical resolver policies are:
|
||||
|
||||
- `anyone`: any authenticated actor in the interaction's company who can read the
|
||||
issue and use the normal resolution route. For agents this includes the creator
|
||||
agent and the creating/source run.
|
||||
- `not_creator`: the explicit independent-review policy. It excludes the creator
|
||||
agent and creating/source run while otherwise using the ordinary agent resolver
|
||||
path.
|
||||
- `human_only`: only an authorized human/board actor may resolve the interaction.
|
||||
|
||||
`board_or_agents` and `board_only` are deprecated migration and API-input aliases.
|
||||
For new writes they normalize to `anyone` and `human_only`, respectively. API reads
|
||||
return canonical requested/effective policies, compatibility aliases, and immutable
|
||||
provenance. The persisted provenance is `explicit`, `inherited`, or
|
||||
`legacy_inherited_restriction`; the effective-policy source is `requested`,
|
||||
`company_cap`, or `governed_action`.
|
||||
|
||||
Historical rows predate explicit-vs-inherited provenance. Migration must never
|
||||
silently widen an ambiguous pending card: legacy `board_or_agents` rows retain the
|
||||
old creator-excluding behavior as canonical `not_creator`, legacy `board_only` rows
|
||||
become `human_only`, and both are marked `legacy_inherited_restriction`. Resolved
|
||||
outcomes and resolver attribution are immutable.
|
||||
|
||||
An explicit named addressee and a company-configured cap may narrow the effective
|
||||
audience. A cap never widens the requested audience. Tool-action confirmations and
|
||||
other hard-governed action cards remain `human_only` (or move to the formal approval
|
||||
system) regardless of a requested open audience.
|
||||
|
||||
Surfaces that offer a resolution must state the effective audience before the
|
||||
operator acts, from server metadata rather than a client-side policy inference.
|
||||
Issue-thread cards read it from the interaction snapshot; attention rows read it
|
||||
from the feed item's `resolverAudience` (canonical requested/effective policy,
|
||||
effective-policy source, provenance, and the addressee/creator identities the
|
||||
evaluator compares against), because a collapsed row carries decision verbs
|
||||
before the interaction itself is fetched. A failed resolution keeps the server's
|
||||
denial reason in visible, assertively announced feedback and names who may
|
||||
respond; an audience denial is permanent, so it must not degrade to a retry
|
||||
prompt. Neither surface may enable or disable a control on its own authority.
|
||||
|
||||
Every resolution remains company-scoped, run-attributed for agent actors,
|
||||
low-trust/task-bridge contained, target-current, and exact-once. Target staleness,
|
||||
supersession, continuation idempotency, and activity attribution remain mandatory.
|
||||
An open audience is not an uncapped one: when an agent run resolves an interaction
|
||||
on an issue other than its own source issue, the resolution is a cross-issue
|
||||
mutation and consumes the per-run cross-issue influence budget in §9.3, charged
|
||||
after audience authorization and before the interaction mutation, child tasks,
|
||||
continuation, tool action, or wake. Same-issue resolutions and board/user
|
||||
resolutions are outside that counter.
|
||||
Accepting or answering an interaction records a response only: suggested-task
|
||||
creation, provider/tool calls, deployment, spend, hiring, secrets, execution-policy
|
||||
decisions, and every other downstream effect must re-run its own authorization and
|
||||
approval checks. Mislabeling a governed action as an open interaction grants no
|
||||
downstream capability.
|
||||
|
||||
## 9.9 Task Watchdog Authority Contract
|
||||
|
||||
A task watchdog is a scoped execution capacity for a configured watchdog agent on one watched issue subtree. It is not a separate principal, does not inherit board auth, and does not expand the selected agent's company boundary. The server must enforce the watchdog contract from persisted watchdog configuration and run context; custom instructions and prompt text can narrow the mandate but cannot expand it.
|
||||
|
|
@ -679,7 +740,8 @@ Within the watched subtree, a watchdog run may perform only mutations that resto
|
|||
- reopen `done` or `cancelled` included issues only with explicit resume metadata and an audit comment when evidence shows the stopped disposition is wrong or incomplete
|
||||
- add, replace, or clear blockers on included issues when the blocker target is in the same company and the change makes the waiting path more accurate
|
||||
- set or refresh a one-shot monitor on an included issue when the current assignee owns the future check
|
||||
- accept or reject eligible task-level plan confirmations as defined below
|
||||
- resolve issue-thread interactions through the ordinary resolver-audience path
|
||||
when the watchdog agent otherwise has issue access and the effective policy allows it
|
||||
- update the reusable watchdog issue itself to `done`, `in_review`, or `blocked` with the evidence for the watchdog decision
|
||||
|
||||
Every watchdog-triggered mutation must write activity with the watchdog id, source issue id, watchdog issue id when present, run id, and stop fingerprint. Mutations still use the normal status-transition, blocker, assignment, budget, and company-boundary guards.
|
||||
|
|
@ -717,19 +779,18 @@ When the safe next action needs one of these disallowed mutations, the watchdog
|
|||
|
||||
### Interaction resolution
|
||||
|
||||
The initial V1 watchdog resolver may resolve exactly one interaction family: `request_confirmation` interactions that are eligible task-level plan confirmations. The watchdog may accept a coherent eligible plan or reject/request changes with a reason. It may not resolve `request_checkbox_confirmation`, `ask_user_questions`, `suggest_tasks`, linked approvals, board approvals, or ad hoc document comments.
|
||||
A task-watchdog run has no special resolver audience, plan-purpose marker, or
|
||||
interaction-kind allowlist. The task-watchdog context neither widens nor
|
||||
categorically removes the selected agent's ordinary interaction authority. The
|
||||
same evaluator used for every agent applies `anyone`, `not_creator`, `human_only`,
|
||||
named-addressee, company-cap, company-boundary, run-attribution, low-trust,
|
||||
task-bridge, target-staleness, and exact-once checks.
|
||||
|
||||
A plan confirmation is eligible only when all of these are true:
|
||||
|
||||
- the interaction is pending and belongs to an issue inside the watched subtree, excluding the reusable watchdog issue and its descendants
|
||||
- the interaction target is an `issue_document` with key `plan` on that same issue, and the target revision is still current
|
||||
- the interaction has an explicit plan-approval purpose marker; title text, body prose, or idempotency key shape alone is not enough
|
||||
- accepting the plan authorizes decomposition or task-level continuation inside the watched subtree only
|
||||
- the plan does not request hiring, budget/spend approval, secret access, production deployment, security-sensitive policy changes, legal/compliance decisions, destructive data changes, cross-company work, or any other board-only governed action
|
||||
- no newer board/user comment, document revision, superseding interaction, custom instruction, or issue policy reserves the decision for a human, CTO, Security, or the board
|
||||
- the plan names concrete child/follow-up work, owners or assignee selection criteria, dependencies/blockers, and acceptance criteria clearly enough that decomposition can proceed without further judgment
|
||||
|
||||
If any condition fails, the watchdog must not accept the interaction. It should reject with a reason when the plan is clearly invalid, or leave/escalate the decision when the right owner is a board user, CTO, Security, or another typed approver.
|
||||
Resolving an interaction does not authorize its downstream effect. In particular,
|
||||
an accepted plan still passes normal decomposition/idempotency checks, and a
|
||||
governed action still requires its own typed reviewer, permission, or formal
|
||||
approval. A watchdog may not use an open coordination response to bypass any item
|
||||
in the disallowed-mutations list above.
|
||||
|
||||
### Downstream acceptance criteria
|
||||
|
||||
|
|
@ -738,8 +799,8 @@ Implementation, security, UI, and QA work for task watchdogs must prove these co
|
|||
- server tests deny cross-company watched issues, watchdog agents, watchdog issues, blockers, interactions, and assignment targets
|
||||
- server tests deny paused, terminated, pending-approval, budget-blocked, or otherwise uninvokable watchdog agents
|
||||
- watchdog-scoped mutations can touch only the watched subtree and the reusable watchdog issue, with activity records for each mutation
|
||||
- interaction tests prove only eligible `request_confirmation` plan confirmations are accepted or rejected, and all other interaction kinds remain unavailable to watchdogs
|
||||
- plan-confirmation tests cover stale document revisions, missing purpose markers, outside-subtree targets, governed actions, newer user comments, and explicit human/CTO/Security reservations
|
||||
- interaction tests prove watchdog runs use the same resolver policy as ordinary agents, without a watchdog-only kind or purpose-marker exception
|
||||
- interaction tests cover `anyone`, `not_creator`, `human_only`, named addressees, company caps, stale targets, governed actions, newer user comments, low-trust/task-bridge containment, and cross-company denial
|
||||
- scheduler tests prove live runs, queued wakes, and scheduled retries suppress watchdog wakeups, while terminal, cancelled, blocked, and review leaves are still verified when the subtree has no live path
|
||||
- tests prove `task_watchdog` origin issues and descendants are excluded from scans so watchdogs do not trigger themselves
|
||||
- recovery-batch tests prove batches are capped at 3 allowed mutations, applied all-or-nothing, and aborted with recorded evidence when the observed stop fingerprint went stale mid-batch
|
||||
|
|
@ -749,7 +810,7 @@ Implementation, security, UI, and QA work for task watchdogs must prove these co
|
|||
- prompt/context tests prove custom instructions are appended after non-overridable safety constraints and cannot expand authority
|
||||
- QA validates a full create/edit/remove/run/reuse flow with screenshots for UI changes
|
||||
|
||||
No unresolved policy decision blocks implementation once CTO and Security accept this contract. Deliberately deferred and disallowed for the first implementation: resolving interaction kinds beyond eligible plan confirmations, letting watchdogs cancel active runs, approving board/governance actions, mutating outside the watched subtree, or allowing watchdog agents to modify their own watchdog configuration. Any expansion requires a new product/security review.
|
||||
No unresolved policy decision blocks implementation once CTO and Security accept this contract. Deliberately deferred and disallowed for the first implementation: letting watchdogs cancel active runs, approving board/governance actions, mutating outside the watched subtree, or allowing watchdog agents to modify their own watchdog configuration. Any expansion of those capabilities requires a new product/security review.
|
||||
|
||||
## 9.10 Company Skill Policy Contract
|
||||
|
||||
|
|
@ -1037,7 +1098,7 @@ Dashboard payload must include:
|
|||
The current app also exposes V1-supporting surfaces for:
|
||||
|
||||
- company-scoped summary slots for projects, the workspaces overview, project workspaces, and individual execution workspaces; execution-workspace slots are keyed by execution workspace id so a new workspace never inherits another workspace's summary
|
||||
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`)
|
||||
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) with the open-default resolver contract in §9.8.1
|
||||
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
|
||||
- company search through `GET /companies/:companyId/search` plus agent-oriented bulk extraction through
|
||||
`GET /companies/:companyId/search/extract`; extraction accepts a server-escaped literal `contains`, optional
|
||||
|
|
|
|||
|
|
@ -101,17 +101,17 @@ On wake, the watchdog agent reads a fixed default mandate plus your custom instr
|
|||
|
||||
- Treat every stopped leaf as a **claim** that must be verified against comments, documents, work products, screenshots, tests, blockers, and review state. Do not accept "I could not" or "waiting for approval" as automatically valid.
|
||||
- Leave genuinely-complete leaves alone, with a short note on what was checked.
|
||||
- If a leaf is not genuinely complete, restore a live path: reopen the issue, reassign, comment actionable instructions, create a follow-up child issue inside the watched subtree, or accept an eligible task-level plan confirmation.
|
||||
- If a leaf is not genuinely complete, restore a live path: reopen the issue, reassign, comment actionable instructions, create a follow-up child issue inside the watched subtree, or resolve an interaction that the ordinary agent audience permits.
|
||||
- If the blocker is real, leave a valid waiting disposition that names the unblock owner and the next action.
|
||||
|
||||
The mandate also enforces safety constraints that custom instructions **cannot override**:
|
||||
|
||||
- Stay inside the watched subtree. No cross-company mutations, no mutations outside the watched issue and its non-watchdog descendants.
|
||||
- No impersonating board-only approvals, accepting spend or hiring decisions, accepting security-sensitive interactions, or bypassing execution-policy stages that require a typed reviewer or approver.
|
||||
- No impersonating formal approvals, accepting spend or hiring decisions, using an interaction response as downstream authority, or bypassing execution-policy stages that require a typed reviewer or approver.
|
||||
- No creating another watchdog for the watched subtree. No waking itself. Exactly one reusable review task per watched issue.
|
||||
- Custom instructions can narrow focus or veto specific shortcuts. They cannot grant authority the server does not already give the watchdog.
|
||||
|
||||
The formal authority contract (the full list of allowed and disallowed mutations, and the eligibility test for accepting plan confirmations) is in [`doc/SPEC-implementation.md`](SPEC-implementation.md) §9.9.
|
||||
The formal authority contract (the full list of allowed and disallowed mutations, plus the ordinary interaction resolver contract) is in [`doc/SPEC-implementation.md`](SPEC-implementation.md) §§9.8.1–9.9.
|
||||
|
||||
### Writing custom instructions
|
||||
|
||||
|
|
@ -119,11 +119,11 @@ Custom instructions are most useful when they tell the watchdog what evidence to
|
|||
|
||||
> Before accepting any leaf as done, check that there is a corresponding green CI run linked in the comments. If there isn't, reopen the leaf and ask for one.
|
||||
|
||||
> Do not accept a `request_confirmation` plan that proposes more than five subtasks without first asking me to review. Leave the issue in review and ping me.
|
||||
> When an interaction is `not_creator` or `human_only`, preserve that restriction. Do not treat the watchdog role as an override.
|
||||
|
||||
> If a leaf is blocked on the marketing team, accept the wait but make sure the unblock owner is named in the blocker reason.
|
||||
|
||||
What custom instructions cannot do: grant authority outside the watched subtree, approve board-level decisions, expand the interaction kinds the watchdog can resolve, or override safety constraints. The server enforces this regardless of what the instructions say.
|
||||
What custom instructions cannot do: grant authority outside the watched subtree, approve formal governance decisions, widen an interaction's resolver audience, or override safety constraints. The server enforces this regardless of what the instructions say.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ Every watchdog-originated mutation is gated by a server-side scope check derived
|
|||
|
||||
- mutations on issues outside the watched subtree (parent-chain walk, depth-limited)
|
||||
- mutations on issues whose company id does not match the watchdog's company
|
||||
- attempts to resolve interactions other than eligible task-level `request_confirmation` plan confirmations (see SPEC §9.9 for eligibility)
|
||||
- interaction resolution outside the ordinary effective audience, named addressee, company/issue scope, run-attribution, low-trust/task-bridge, target-staleness, or exact-once checks
|
||||
- changes to the watchdog configuration itself (a watchdog cannot edit its own row or create another watchdog)
|
||||
- direct edits to active-run output or execution-policy decisions that require a typed participant
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ It is **not** the right tool for:
|
|||
|
||||
- monitoring a single running process for silence — that is the silent active-run watchdog, automatic, no configuration
|
||||
- liveness recovery on stalled agent-owned issues without an explicit recovery surface — that is automatic too
|
||||
- board-level approvals or anything security-sensitive — the watchdog cannot resolve those
|
||||
- formal approvals or security-sensitive downstream actions — an interaction response cannot authorize those
|
||||
- replacing a human reviewer on a typed execution-policy stage — the watchdog cannot bypass typed participants
|
||||
|
||||
If what you actually want is "wake me when this is done," use a routine or an issue-thread interaction with `continuationPolicy: wake_assignee`, not a watchdog.
|
||||
|
|
|
|||
|
|
@ -575,6 +575,28 @@ On startup and on the periodic recovery loop, Paperclip now does five things in
|
|||
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
|
||||
|
||||
### Issue-thread interaction resolution
|
||||
|
||||
Every issue-thread interaction kind inherits resolver policy `anyone` when the
|
||||
creator omits a policy. `anyone` includes the creator agent and creating run.
|
||||
Callers opt into independent review with `not_creator` or human resolution with
|
||||
`human_only`; a named addressee and company cap may narrow the effective audience.
|
||||
The legacy input aliases `board_or_agents` and `board_only` normalize to `anyone`
|
||||
and `human_only` for new writes.
|
||||
|
||||
Resolver policy is snapshotted with explicit/inherited provenance and an effective
|
||||
source (`requested`, `company_cap`, or `governed_action`). Existing ambiguous
|
||||
legacy rows are marked `legacy_inherited_restriction` and retain their old
|
||||
restriction: `board_or_agents` becomes `not_creator`, not `anyone`, while
|
||||
`board_only` becomes `human_only`. Pending cards are never silently widened.
|
||||
|
||||
Resolution is exact-once and requires issue access in the same company. Agent
|
||||
resolution also requires valid run attribution and remains subject to low-trust
|
||||
and task-bridge containment. Target freshness and supersession are checked before
|
||||
the outcome commits. Resolution records an answer; every continuation, task
|
||||
creation, tool/provider call, execution-policy transition, spend, deployment, or
|
||||
other effect independently re-runs its own authorization and approval gates.
|
||||
|
||||
## 11. Task Watchdog for Issue Trees
|
||||
|
||||
A task watchdog watches a configured issue subtree after that subtree has stopped moving. It is a product-level verification and recovery mechanism for selected work, not a process monitor.
|
||||
|
|
@ -623,13 +645,13 @@ When the source issue is non-terminal and has no other live path, the product sh
|
|||
|
||||
### Watchdog authority during execution
|
||||
|
||||
The watchdog agent acts in a scoped capacity, not as the original deliverable worker and not as the board. The server must enforce the authority contract in `doc/SPEC-implementation.md` from persisted watchdog context. Prompt text and custom instructions may guide the watchdog's judgment, but they cannot grant authority outside the watched subtree or beyond the allowed mutation and interaction list.
|
||||
The watchdog agent acts in a scoped capacity, not as the original deliverable worker and not as the board. The server must enforce the authority contract in `doc/SPEC-implementation.md` from persisted watchdog context. Prompt text and custom instructions may guide the watchdog's judgment, but they cannot grant authority outside the watched subtree or widen an interaction's ordinary effective audience.
|
||||
|
||||
Watchdogs must not create visible probe issues, comments, or throwaway tasks to discover capability boundaries. They should rely on the wake capability metadata and explicit API denials, then record any denied operation as evidence in the reusable watchdog issue.
|
||||
|
||||
The watchdog should verify stopped leaves against comments, documents, work products, tests, screenshots, blockers, review state, and run context. It should not accept "I could not" or "waiting for approval" as sufficient by itself.
|
||||
|
||||
When work should continue, the watchdog restores a live path inside the watched subtree: reopen or reassign stuck work, create follow-up issues, repair blockers, set a monitor, or resolve an eligible plan confirmation. When the stopped state is legitimate, the watchdog records why and leaves the subtree with a valid terminal, waiting, blocked, review, or explicit recovery path.
|
||||
When work should continue, the watchdog restores a live path inside the watched subtree: reopen or reassign stuck work, create follow-up issues, repair blockers, set a monitor, or resolve an interaction that its ordinary agent audience permits. When the stopped state is legitimate, the watchdog records why and leaves the subtree with a valid terminal, waiting, blocked, review, or explicit recovery path.
|
||||
|
||||
### Atomic recovery batch
|
||||
|
||||
|
|
@ -637,16 +659,19 @@ Restoration is often more than one write — reopen the dead-end leaf **and** ex
|
|||
|
||||
This preserves the stale-guard's purpose — never keep mutating a subtree that just went live under the watchdog's feet — while removing the failure mode where spending the only permitted write on an informational comment forfeits the state-restoring mutation the recovery actually needed.
|
||||
|
||||
### Eligible interaction decisions
|
||||
### Interaction decisions
|
||||
|
||||
A task watchdog may resolve only eligible `request_confirmation` plan confirmations. Eligibility is defined in `doc/SPEC-implementation.md` and must be checked by the server at decision time. The critical constraints are:
|
||||
A task watchdog is an ordinary agent for interaction resolution. Its watchdog
|
||||
context provides no special audience, plan-purpose marker, or kind allowlist, and
|
||||
it is not a categorical denial. The normal evaluator checks the effective policy,
|
||||
named addressee, company and issue scope, run attribution, low-trust/task-bridge
|
||||
containment, target freshness, and exact-once state.
|
||||
|
||||
- the interaction is pending, targeted at the current `plan` document revision for an included subtree issue, and explicitly marked as a plan-approval confirmation
|
||||
- accepting it authorizes only decomposition or task-level continuation inside the watched subtree
|
||||
- the plan is not asking for board-only governance, spend, hiring, security, deployment, secret, destructive data, legal/compliance, cross-company, or other sensitive approval
|
||||
- no newer durable source activity or policy reserves the decision for a human, CTO, Security, or the board
|
||||
|
||||
The watchdog cannot resolve `request_checkbox_confirmation`, `ask_user_questions`, `suggest_tasks`, linked approvals, execution-policy decisions unless it is the typed participant outside watchdog capacity, or document comments written as freeform approval.
|
||||
This does not give a watchdog downstream authority. Linked/formal approvals remain
|
||||
separate, execution-policy decisions still require the typed participant, and an
|
||||
accepted interaction cannot authorize spend, hiring, secrets, deployment,
|
||||
destructive data changes, cross-company work, or any mutation the watchdog scope
|
||||
otherwise forbids.
|
||||
|
||||
### Completion and fingerprint updates
|
||||
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ POST /api/issues/{issueId}/comments
|
|||
|
||||
## Issue-Thread Interactions
|
||||
|
||||
Interactions are structured cards in the issue thread. Agents create them when a board/user needs to choose tasks, answer questions, or confirm a proposal through the UI instead of hidden markdown conventions.
|
||||
Interactions are structured cards in the issue thread. Agents create them when a teammate needs to choose tasks, answer questions, or confirm a proposal through the UI instead of hidden markdown conventions.
|
||||
|
||||
### List Interactions
|
||||
|
||||
|
|
@ -193,7 +193,7 @@ GET /api/issues/{issueId}/interactions
|
|||
POST /api/issues/{issueId}/interactions
|
||||
{
|
||||
"kind": "request_confirmation",
|
||||
"resolverPolicy": "board_only",
|
||||
"resolverPolicy": "human_only",
|
||||
"idempotencyKey": "confirmation:{issueId}:plan:{revisionId}",
|
||||
"title": "Plan approval",
|
||||
"summary": "Waiting for the board/user to accept or request changes.",
|
||||
|
|
@ -227,9 +227,11 @@ Supported `kind` values:
|
|||
- `request_checkbox_confirmation`: ask for one accept/reject decision over selected option ids
|
||||
- `request_item_verdicts`: collect approve/reject/defer verdicts per item
|
||||
|
||||
`resolverPolicy: "board_only" | "board_or_agents"`. Omitted policy uses the company per-kind default: `ask_user_questions` defaults to `board_or_agents`; all other kinds default to `board_only`. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance`, keyed by kind, with optional `defaultPolicy` and `cap`. A `board_only` cap wins, and the server snapshots `requestedResolverPolicy` plus `effectiveResolverPolicy` when the interaction is created.
|
||||
Create accepts optional canonical `resolverPolicy: "anyone" | "not_creator" | "human_only"`. Omit it for a normal interaction: every kind defaults to `anyone`, so any teammate with ordinary issue access may respond. Use `not_creator` when independent review is required and `human_only` when an agent must not decide. Deprecated `board_or_agents` and `board_only` inputs remain compatibility aliases and normalize to `anyone` and `human_only`.
|
||||
|
||||
`addresseeAgentId` optionally targets a same-company agent. The addressee is woken with `interaction_pending`, and only that agent or a board user may resolve the card; the creator cannot address itself, tool-action confirmations with an addressee return `400`, and all low-trust/watchdog/same-run restrictions remain. Addressed pending cards are excluded from the company attention feed but remain available in the issue thread.
|
||||
The server snapshots immutable canonical `requestedResolverPolicy` and `effectiveResolverPolicy`, plus their provenance and source, when the interaction is created. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance`, keyed by kind, with optional `defaultPolicy` and `cap`; governance may narrow but never widen the requested audience. Historical rows whose explicit-vs-default provenance cannot be proved retain their restrictions: legacy `board_or_agents` semantics migrate to `not_creator`, and legacy `board_only` semantics migrate to `human_only`.
|
||||
|
||||
`addresseeAgentId` optionally targets a same-company agent. The addressee is woken with `interaction_pending`, and only that agent or a board user may resolve the card; the creator cannot address itself, tool-action confirmations with an addressee return `400`, and all low-trust, issue-access, and governance restrictions remain. Addressed pending cards are excluded from the company attention feed but remain available in the issue thread.
|
||||
|
||||
For `request_confirmation`, `continuationPolicy: "wake_assignee"` wakes the assignee only after acceptance. Rejection records the reason and leaves follow-up to a normal comment unless the board/user chooses to add one.
|
||||
|
||||
|
|
@ -243,7 +245,9 @@ POST /api/issues/{issueId}/interactions/{interactionId}/verdicts
|
|||
POST /api/issues/{issueId}/interactions/{interactionId}/withdraw
|
||||
```
|
||||
|
||||
Board users can resolve all interactions. Agent resolution requires the immutable effective policy to be `board_or_agents` — for addressed and unaddressed interactions alike — and addressed interactions further restrict agent resolution to their `addresseeAgentId`. Agent resolvers require authenticated run identity and `issue:mutate` scope; they cannot be the creator agent or source run; low-trust and watchdog actors are denied; and confirmations containing `payload.toolAction` are always board-only. Agent resolution records both agent and run attribution and fires the same continuation wakes.
|
||||
Board users can resolve all interactions. Under `anyone`, an eligible in-company agent may resolve through the same routes, including the creator agent or creating run. `not_creator` excludes those creators, and `human_only` excludes agents. Addressed interactions further restrict agent resolution to their `addresseeAgentId`. Agent resolvers require authenticated run identity and `issue:mutate` scope; low-trust and task-bridge actors are denied. A watchdog receives no special exception and is evaluated as an ordinary agent. Confirmations containing `payload.toolAction` are always `human_only`. Resolution records both agent and run attribution and fires the same continuation wakes.
|
||||
|
||||
Resolving a card records the response only. Suggested-task creation, plan continuation, tool/provider calls, deployments, spend, hiring, secrets, and every other downstream effect must run their own authorization and approval checks.
|
||||
|
||||
The creator agent or a board user may withdraw a pending interaction. Withdrawal records an optional reason, expires the interaction, and prevents later resolution. Low-trust and task-watchdog agent runs cannot withdraw interactions.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./test-embedded-postgres.js";
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const d = support.supported ? describe : describe.skip;
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function resolverPolicyMigrationStatements(): Promise<string[]> {
|
||||
const migrationSql = await readFile(
|
||||
fileURLToPath(new URL("./migrations/0218_mushy_jack_murdock.sql", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
return migrationSql
|
||||
.split("--> statement-breakpoint")
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
d("issue interaction resolver policy migration", () => {
|
||||
it("replays safely and completes a partial prior run", async () => {
|
||||
const dbh = await startEmbeddedPostgresTestDatabase("resolver-policy-migration-");
|
||||
cleanups.push(() => dbh.cleanup());
|
||||
const sql = postgres(dbh.connectionString, { max: 1, onnotice: () => {} });
|
||||
cleanups.push(async () => {
|
||||
await sql.end();
|
||||
});
|
||||
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const legacyInteractionId = randomUUID();
|
||||
const partialInteractionId = randomUUID();
|
||||
|
||||
await sql`
|
||||
INSERT INTO "companies" ("id", "name", "issue_prefix")
|
||||
VALUES (${companyId}, 'Resolver migration test', 'RMT')
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
|
||||
VALUES (${issueId}, ${companyId}, 'Test migration replay', 'RMT-1')
|
||||
`;
|
||||
await sql.unsafe(
|
||||
'ALTER TABLE "issue_thread_interactions" ALTER COLUMN "resolver_policy_provenance" DROP NOT NULL',
|
||||
);
|
||||
await sql.unsafe(
|
||||
'ALTER TABLE "issue_thread_interactions" ALTER COLUMN "effective_resolver_policy_source" DROP NOT NULL',
|
||||
);
|
||||
await sql`
|
||||
INSERT INTO "issue_thread_interactions" (
|
||||
"id",
|
||||
"company_id",
|
||||
"issue_id",
|
||||
"kind",
|
||||
"requested_resolver_policy",
|
||||
"effective_resolver_policy",
|
||||
"resolver_policy_provenance",
|
||||
"effective_resolver_policy_source",
|
||||
"payload"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
${legacyInteractionId},
|
||||
${companyId},
|
||||
${issueId},
|
||||
'request_confirmation',
|
||||
'board_or_agents',
|
||||
'board_only',
|
||||
NULL,
|
||||
NULL,
|
||||
'{}'::jsonb
|
||||
),
|
||||
(
|
||||
${partialInteractionId},
|
||||
${companyId},
|
||||
${issueId},
|
||||
'request_confirmation',
|
||||
'human_only',
|
||||
'human_only',
|
||||
'explicit',
|
||||
NULL,
|
||||
'{}'::jsonb
|
||||
)
|
||||
`;
|
||||
|
||||
const statements = await resolverPolicyMigrationStatements();
|
||||
expect(statements.length).toBeGreaterThan(0);
|
||||
for (const statement of statements) await sql.unsafe(statement);
|
||||
|
||||
const rowsAfterFirstRun = await sql<{
|
||||
id: string;
|
||||
requested_resolver_policy: string;
|
||||
effective_resolver_policy: string;
|
||||
resolver_policy_provenance: string;
|
||||
effective_resolver_policy_source: string;
|
||||
}[]>`
|
||||
SELECT
|
||||
"id",
|
||||
"requested_resolver_policy",
|
||||
"effective_resolver_policy",
|
||||
"resolver_policy_provenance",
|
||||
"effective_resolver_policy_source"
|
||||
FROM "issue_thread_interactions"
|
||||
WHERE "id" IN (${legacyInteractionId}, ${partialInteractionId})
|
||||
ORDER BY "id"
|
||||
`;
|
||||
expect(rowsAfterFirstRun).toEqual(
|
||||
[
|
||||
{
|
||||
id: legacyInteractionId,
|
||||
requested_resolver_policy: "not_creator",
|
||||
effective_resolver_policy: "human_only",
|
||||
resolver_policy_provenance: "legacy_inherited_restriction",
|
||||
effective_resolver_policy_source: "company_cap",
|
||||
},
|
||||
{
|
||||
id: partialInteractionId,
|
||||
requested_resolver_policy: "human_only",
|
||||
effective_resolver_policy: "human_only",
|
||||
resolver_policy_provenance: "explicit",
|
||||
effective_resolver_policy_source: "requested",
|
||||
},
|
||||
].sort((left, right) => left.id.localeCompare(right.id)),
|
||||
);
|
||||
|
||||
for (const statement of statements) await sql.unsafe(statement);
|
||||
const rowsAfterReplay = await sql`
|
||||
SELECT
|
||||
"id",
|
||||
"requested_resolver_policy",
|
||||
"effective_resolver_policy",
|
||||
"resolver_policy_provenance",
|
||||
"effective_resolver_policy_source"
|
||||
FROM "issue_thread_interactions"
|
||||
WHERE "id" IN (${legacyInteractionId}, ${partialInteractionId})
|
||||
ORDER BY "id"
|
||||
`;
|
||||
expect(rowsAfterReplay).toEqual(rowsAfterFirstRun);
|
||||
|
||||
const columns = await sql<
|
||||
{ column_name: string; is_nullable: string; column_default: string }[]
|
||||
>`
|
||||
SELECT "column_name", "is_nullable", "column_default"
|
||||
FROM "information_schema"."columns"
|
||||
WHERE "table_schema" = 'public'
|
||||
AND "table_name" = 'issue_thread_interactions'
|
||||
AND "column_name" IN ('resolver_policy_provenance', 'effective_resolver_policy_source')
|
||||
ORDER BY "column_name"
|
||||
`;
|
||||
expect(columns).toEqual([
|
||||
{
|
||||
column_name: "effective_resolver_policy_source",
|
||||
is_nullable: "NO",
|
||||
column_default: "'requested'::text",
|
||||
},
|
||||
{
|
||||
column_name: "resolver_policy_provenance",
|
||||
is_nullable: "NO",
|
||||
column_default: "'inherited'::text",
|
||||
},
|
||||
]);
|
||||
}, 240_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
ALTER TABLE "issue_thread_interactions" ALTER COLUMN "requested_resolver_policy" SET DEFAULT 'anyone';--> statement-breakpoint
|
||||
ALTER TABLE "issue_thread_interactions" ALTER COLUMN "effective_resolver_policy" SET DEFAULT 'anyone';--> statement-breakpoint
|
||||
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "resolver_policy_provenance" text;--> statement-breakpoint
|
||||
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "effective_resolver_policy_source" text;--> statement-breakpoint
|
||||
-- Existing rows predate provenance, so their explicit-vs-default origin cannot
|
||||
-- be proven. Preserve the old creator-excluding board_or_agents behavior as
|
||||
-- not_creator and the old board_only behavior as human_only. This deliberately
|
||||
-- avoids widening any ambiguous pending card during migration.
|
||||
UPDATE "issue_thread_interactions"
|
||||
SET
|
||||
"requested_resolver_policy" = CASE "requested_resolver_policy"
|
||||
WHEN 'board_or_agents' THEN 'not_creator'
|
||||
WHEN 'board_only' THEN 'human_only'
|
||||
ELSE "requested_resolver_policy"
|
||||
END,
|
||||
"effective_resolver_policy" = CASE "effective_resolver_policy"
|
||||
WHEN 'board_or_agents' THEN 'not_creator'
|
||||
WHEN 'board_only' THEN 'human_only'
|
||||
ELSE "effective_resolver_policy"
|
||||
END,
|
||||
"resolver_policy_provenance" = COALESCE(
|
||||
"resolver_policy_provenance",
|
||||
'legacy_inherited_restriction'
|
||||
),
|
||||
"effective_resolver_policy_source" = COALESCE(
|
||||
"effective_resolver_policy_source",
|
||||
CASE
|
||||
WHEN "kind" = 'request_confirmation' AND "payload" ? 'toolAction' THEN 'governed_action'
|
||||
WHEN "effective_resolver_policy" IS DISTINCT FROM "requested_resolver_policy" THEN 'company_cap'
|
||||
ELSE 'requested'
|
||||
END
|
||||
)
|
||||
WHERE "resolver_policy_provenance" IS NULL
|
||||
OR "effective_resolver_policy_source" IS NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issue_thread_interactions" ALTER COLUMN "resolver_policy_provenance" SET DEFAULT 'inherited';--> statement-breakpoint
|
||||
ALTER TABLE "issue_thread_interactions" ALTER COLUMN "resolver_policy_provenance" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issue_thread_interactions" ALTER COLUMN "effective_resolver_policy_source" SET DEFAULT 'requested';--> statement-breakpoint
|
||||
ALTER TABLE "issue_thread_interactions" ALTER COLUMN "effective_resolver_policy_source" SET NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1513,6 +1513,13 @@
|
|||
"when": 1786643974972,
|
||||
"tag": "0217_yielding_starbolt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 218,
|
||||
"version": "7",
|
||||
"when": 1786711898729,
|
||||
"tag": "0218_mushy_jack_murdock",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import type {
|
||||
IssueThreadInteractionCanonicalResolverPolicy,
|
||||
IssueThreadInteractionEffectiveResolverPolicySource,
|
||||
IssueThreadInteractionPayload,
|
||||
IssueThreadInteractionResolverPolicy,
|
||||
IssueThreadInteractionResolverPolicyProvenance,
|
||||
IssueThreadInteractionResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
|
@ -21,13 +23,21 @@ export const issueThreadInteractions = pgTable(
|
|||
status: text("status").notNull().default("pending"),
|
||||
continuationPolicy: text("continuation_policy").notNull().default("wake_assignee"),
|
||||
requestedResolverPolicy: text("requested_resolver_policy")
|
||||
.$type<IssueThreadInteractionResolverPolicy>()
|
||||
.$type<IssueThreadInteractionCanonicalResolverPolicy>()
|
||||
.notNull()
|
||||
.default("board_only"),
|
||||
.default("anyone"),
|
||||
effectiveResolverPolicy: text("effective_resolver_policy")
|
||||
.$type<IssueThreadInteractionResolverPolicy>()
|
||||
.$type<IssueThreadInteractionCanonicalResolverPolicy>()
|
||||
.notNull()
|
||||
.default("board_only"),
|
||||
.default("anyone"),
|
||||
resolverPolicyProvenance: text("resolver_policy_provenance")
|
||||
.$type<IssueThreadInteractionResolverPolicyProvenance>()
|
||||
.notNull()
|
||||
.default("inherited"),
|
||||
effectiveResolverPolicySource: text("effective_resolver_policy_source")
|
||||
.$type<IssueThreadInteractionEffectiveResolverPolicySource>()
|
||||
.notNull()
|
||||
.default("requested"),
|
||||
idempotencyKey: text("idempotency_key"),
|
||||
sourceCommentId: uuid("source_comment_id").references(() => issueComments.id, { onDelete: "set null" }),
|
||||
sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
|
|
|
|||
|
|
@ -269,13 +269,65 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [
|
|||
] as const;
|
||||
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
|
||||
|
||||
export const ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES = [
|
||||
"board_only",
|
||||
export const ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES = [
|
||||
"anyone",
|
||||
"not_creator",
|
||||
"human_only",
|
||||
] as const;
|
||||
export type IssueThreadInteractionCanonicalResolverPolicy =
|
||||
(typeof ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES)[number];
|
||||
|
||||
export const ISSUE_THREAD_INTERACTION_LEGACY_RESOLVER_POLICY_ALIASES = [
|
||||
"board_or_agents",
|
||||
"board_only",
|
||||
] as const;
|
||||
export type IssueThreadInteractionLegacyResolverPolicyAlias =
|
||||
(typeof ISSUE_THREAD_INTERACTION_LEGACY_RESOLVER_POLICY_ALIASES)[number];
|
||||
|
||||
/**
|
||||
* Accepted resolver-policy input values. New product surfaces should use the
|
||||
* canonical values; the two board-prefixed values remain write-compatible
|
||||
* aliases for one migration window.
|
||||
*/
|
||||
export const ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES = [
|
||||
...ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES,
|
||||
...ISSUE_THREAD_INTERACTION_LEGACY_RESOLVER_POLICY_ALIASES,
|
||||
] as const;
|
||||
export type IssueThreadInteractionResolverPolicy =
|
||||
(typeof ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES)[number];
|
||||
|
||||
export const ISSUE_THREAD_INTERACTION_RESOLVER_POLICY_PROVENANCES = [
|
||||
"explicit",
|
||||
"inherited",
|
||||
"legacy_inherited_restriction",
|
||||
] as const;
|
||||
export type IssueThreadInteractionResolverPolicyProvenance =
|
||||
(typeof ISSUE_THREAD_INTERACTION_RESOLVER_POLICY_PROVENANCES)[number];
|
||||
|
||||
export const ISSUE_THREAD_INTERACTION_EFFECTIVE_RESOLVER_POLICY_SOURCES = [
|
||||
"requested",
|
||||
"company_cap",
|
||||
"governed_action",
|
||||
] as const;
|
||||
export type IssueThreadInteractionEffectiveResolverPolicySource =
|
||||
(typeof ISSUE_THREAD_INTERACTION_EFFECTIVE_RESOLVER_POLICY_SOURCES)[number];
|
||||
|
||||
export function normalizeIssueThreadInteractionResolverPolicy(
|
||||
policy: IssueThreadInteractionResolverPolicy,
|
||||
): IssueThreadInteractionCanonicalResolverPolicy {
|
||||
if (policy === "board_or_agents") return "anyone";
|
||||
if (policy === "board_only") return "human_only";
|
||||
return policy;
|
||||
}
|
||||
|
||||
export function legacyIssueThreadInteractionResolverPolicyAlias(
|
||||
policy: IssueThreadInteractionCanonicalResolverPolicy,
|
||||
): IssueThreadInteractionLegacyResolverPolicyAlias | null {
|
||||
if (policy === "anyone") return "board_or_agents";
|
||||
if (policy === "human_only") return "board_only";
|
||||
return null;
|
||||
}
|
||||
|
||||
export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200;
|
||||
export const REQUEST_ITEM_VERDICTS_ITEM_LIMIT = REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT;
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ export type {
|
|||
AttentionFeedQuery,
|
||||
AttentionProjectRef,
|
||||
AttentionQueueRef,
|
||||
AttentionResolverAudience,
|
||||
AttentionSeverity,
|
||||
AttentionSortMode,
|
||||
AttentionSourceKind,
|
||||
|
|
@ -284,7 +285,13 @@ export {
|
|||
ISSUE_COMMENT_PRESENTATION_DENSITIES,
|
||||
clampIssueRequestDepth,
|
||||
ISSUE_THREAD_INTERACTION_KINDS,
|
||||
ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES,
|
||||
ISSUE_THREAD_INTERACTION_LEGACY_RESOLVER_POLICY_ALIASES,
|
||||
ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES,
|
||||
ISSUE_THREAD_INTERACTION_RESOLVER_POLICY_PROVENANCES,
|
||||
ISSUE_THREAD_INTERACTION_EFFECTIVE_RESOLVER_POLICY_SOURCES,
|
||||
normalizeIssueThreadInteractionResolverPolicy,
|
||||
legacyIssueThreadInteractionResolverPolicyAlias,
|
||||
ISSUE_THREAD_INTERACTION_STATUSES,
|
||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||
ISSUE_ORIGIN_KINDS,
|
||||
|
|
@ -471,7 +478,11 @@ export {
|
|||
type IssueCommentPresentationTone,
|
||||
type IssueCommentPresentationDensity,
|
||||
type IssueThreadInteractionKind,
|
||||
type IssueThreadInteractionCanonicalResolverPolicy,
|
||||
type IssueThreadInteractionLegacyResolverPolicyAlias,
|
||||
type IssueThreadInteractionResolverPolicy,
|
||||
type IssueThreadInteractionResolverPolicyProvenance,
|
||||
type IssueThreadInteractionEffectiveResolverPolicySource,
|
||||
type IssueThreadInteractionStatus,
|
||||
type IssueThreadInteractionContinuationPolicy,
|
||||
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,50 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES,
|
||||
ISSUE_THREAD_INTERACTION_LEGACY_RESOLVER_POLICY_ALIASES,
|
||||
legacyIssueThreadInteractionResolverPolicyAlias,
|
||||
normalizeIssueThreadInteractionResolverPolicy,
|
||||
} from "./constants.js";
|
||||
import {
|
||||
acceptIssueThreadInteractionSchema,
|
||||
askUserQuestionsResultSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
requestConfirmationPayloadSchema,
|
||||
requestConfirmationResultSchema,
|
||||
requestItemVerdictsResultSchema,
|
||||
submitIssueThreadInteractionVerdictsSchema,
|
||||
} from "./validators/issue.js";
|
||||
|
||||
describe("issue thread interaction schemas", () => {
|
||||
it("defines canonical resolver policies and normalizes compatibility aliases", () => {
|
||||
expect(ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES).toEqual([
|
||||
"anyone",
|
||||
"not_creator",
|
||||
"human_only",
|
||||
]);
|
||||
expect(ISSUE_THREAD_INTERACTION_LEGACY_RESOLVER_POLICY_ALIASES).toEqual([
|
||||
"board_or_agents",
|
||||
"board_only",
|
||||
]);
|
||||
expect(normalizeIssueThreadInteractionResolverPolicy("board_or_agents")).toBe("anyone");
|
||||
expect(normalizeIssueThreadInteractionResolverPolicy("board_only")).toBe("human_only");
|
||||
expect(normalizeIssueThreadInteractionResolverPolicy("not_creator")).toBe("not_creator");
|
||||
expect(legacyIssueThreadInteractionResolverPolicyAlias("anyone")).toBe("board_or_agents");
|
||||
expect(legacyIssueThreadInteractionResolverPolicyAlias("not_creator")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["anyone", "not_creator", "human_only", "board_or_agents", "board_only"] as const)(
|
||||
"accepts resolver policy input %s",
|
||||
(resolverPolicy) => {
|
||||
const parsed = createIssueThreadInteractionSchema.parse({
|
||||
kind: "request_confirmation",
|
||||
resolverPolicy,
|
||||
payload: { version: 1, prompt: "Proceed?" },
|
||||
});
|
||||
expect(parsed.resolverPolicy).toBe(resolverPolicy);
|
||||
},
|
||||
);
|
||||
|
||||
it("parses request_confirmation payloads with default no-wake continuation", () => {
|
||||
const parsed = createIssueThreadInteractionSchema.parse({
|
||||
kind: "request_confirmation",
|
||||
|
|
@ -98,6 +134,26 @@ describe("issue thread interaction schemas", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("accepts run-attributed agent item verdict results and rejects missing runs", () => {
|
||||
const result = {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete: true,
|
||||
items: [{
|
||||
id: "api",
|
||||
verdict: "approve",
|
||||
resolvedByAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
resolvedByRunId: "22222222-2222-4222-8222-222222222222",
|
||||
resolvedAt: "2026-08-14T12:00:00.000Z",
|
||||
}],
|
||||
};
|
||||
expect(requestItemVerdictsResultSchema.parse(result)).toMatchObject(result);
|
||||
expect(() => requestItemVerdictsResultSchema.parse({
|
||||
...result,
|
||||
items: [{ ...result.items[0], resolvedByRunId: undefined }],
|
||||
})).toThrow("resolvedByRunId is required for an agent resolver");
|
||||
});
|
||||
|
||||
it("accepts issue document targets for request_confirmation interactions", () => {
|
||||
const parsed = createIssueThreadInteractionSchema.parse({
|
||||
kind: "request_confirmation",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
# Telemetry Data Contract
|
||||
|
||||
This document explains how contributors should use Paperclip's public telemetry
|
||||
contract. It intentionally does not list individual events or dimensions.
|
||||
contract. It does not duplicate the full list of individual events or
|
||||
dimensions. It documents extra semantic and privacy rules where the generated
|
||||
shape is not sufficient.
|
||||
|
||||
The canonical source for first-party event names, dimensions, optionality,
|
||||
allowed primitive value types, and enum descriptions is
|
||||
|
|
@ -55,6 +57,23 @@ If a dimension is privacy-protected before emission, emit only the protected
|
|||
value and its matching public marker as defined by the typed helper or generated
|
||||
contract. Do not emit private source material in telemetry dimensions.
|
||||
|
||||
## Interaction Resolver Events
|
||||
|
||||
`interaction.created` records the interaction kind and whether the create
|
||||
request used a deprecated resolver-policy alias. It does not record the prompt,
|
||||
title, options, questions, target identifier, creator identifier, or resolver
|
||||
identifier.
|
||||
|
||||
`interaction.resolved` records the low-cardinality interaction outcome defined
|
||||
in the generated contract. Its `legacy_inherited_restriction` dimension is
|
||||
`true` only when stored migration provenance preserves a legacy resolver-policy
|
||||
restriction. It is `false` for canonical new writes. This dimension describes
|
||||
policy provenance. It does not contain user content or an identifier.
|
||||
|
||||
Use `trackInteractionCreated()` and `trackInteractionResolved()` from
|
||||
`events.ts` to emit these events. The generated contract remains the authority
|
||||
for their exact dimensions and optionality.
|
||||
|
||||
## Sandbox Startup Trace Spans
|
||||
|
||||
Paperclip opens OpenTelemetry spans on the sandbox start path. These spans are a
|
||||
|
|
|
|||
|
|
@ -129,6 +129,19 @@ export function trackErrorHandlerCrash(
|
|||
client.track("error.handler_crash", { error_code: dims.errorCode });
|
||||
}
|
||||
|
||||
export function trackInteractionCreated(
|
||||
client: TelemetryClient,
|
||||
dims: {
|
||||
interactionKind: RawDimension<EventDimensionsMap["interaction.created"]["interaction_kind"]>;
|
||||
usedDeprecatedResolverPolicyAlias: boolean;
|
||||
},
|
||||
): void {
|
||||
client.track("interaction.created", {
|
||||
interaction_kind: asEventDimension(dims.interactionKind),
|
||||
used_deprecated_resolver_policy_alias: dims.usedDeprecatedResolverPolicyAlias,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackInteractionResolved(
|
||||
client: TelemetryClient,
|
||||
dims: {
|
||||
|
|
@ -146,6 +159,7 @@ export function trackInteractionResolved(
|
|||
answeredQuestionCount?: number;
|
||||
createdTaskCount?: number;
|
||||
skippedTaskCount?: number;
|
||||
legacyInheritedRestriction: boolean;
|
||||
},
|
||||
): void {
|
||||
client.track("interaction.resolved", {
|
||||
|
|
@ -163,5 +177,6 @@ export function trackInteractionResolved(
|
|||
...(dims.answeredQuestionCount === undefined ? {} : { answered_question_count: dims.answeredQuestionCount }),
|
||||
...(dims.createdTaskCount === undefined ? {} : { created_task_count: dims.createdTaskCount }),
|
||||
...(dims.skippedTaskCount === undefined ? {} : { skipped_task_count: dims.skippedTaskCount }),
|
||||
legacy_inherited_restriction: dims.legacyInheritedRestriction,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,13 @@ export interface PaperclipInstallStartedDimensions {
|
|||
|
||||
}
|
||||
|
||||
export interface PaperclipInteractionCreatedDimensions {
|
||||
interaction_kind: ("suggest_tasks" | "ask_user_questions" | "request_confirmation" | "request_checkbox_confirmation" | "request_item_verdicts" | "other")
|
||||
used_deprecated_resolver_policy_alias: boolean
|
||||
}
|
||||
|
||||
export interface PaperclipInteractionResolvedDimensions {
|
||||
interaction_kind: ("suggest_tasks" | "ask_user_questions" | "request_confirmation" | "request_checkbox_confirmation" | "other")
|
||||
interaction_kind: ("suggest_tasks" | "ask_user_questions" | "request_confirmation" | "request_checkbox_confirmation" | "request_item_verdicts" | "other")
|
||||
status: ("accepted" | "rejected" | "answered" | "cancelled" | "expired" | "failed" | "other")
|
||||
resolution_reason?: ("accepted" | "rejected" | "stale_target" | "superseded_by_comment" | "superseded_by_newer_request" | "expired" | "cancelled" | "other")
|
||||
resolved_by_kind: ("user" | "agent" | "system" | "other")
|
||||
|
|
@ -59,6 +64,7 @@ resolution_latency_seconds?: number
|
|||
interaction_id?: string
|
||||
created_by_agent_id?: string
|
||||
source_run_id?: string
|
||||
legacy_inherited_restriction: boolean
|
||||
}
|
||||
|
||||
export interface PaperclipProjectCreatedDimensions {
|
||||
|
|
@ -88,6 +94,7 @@ export type PaperclipEventName =
|
|||
| "goal.created"
|
||||
| "install.completed"
|
||||
| "install.started"
|
||||
| "interaction.created"
|
||||
| "interaction.resolved"
|
||||
| "project.created"
|
||||
| "routine.created"
|
||||
|
|
@ -103,6 +110,7 @@ export interface EventDimensionsMap {
|
|||
"goal.created": PaperclipGoalCreatedDimensions;
|
||||
"install.completed": PaperclipInstallCompletedDimensions;
|
||||
"install.started": PaperclipInstallStartedDimensions;
|
||||
"interaction.created": PaperclipInteractionCreatedDimensions;
|
||||
"interaction.resolved": PaperclipInteractionResolvedDimensions;
|
||||
"project.created": PaperclipProjectCreatedDimensions;
|
||||
"routine.created": PaperclipRoutineCreatedDimensions;
|
||||
|
|
@ -119,6 +127,7 @@ export const PAPERCLIP_EVENTS = {
|
|||
"goal.created": "goal.created",
|
||||
"install.completed": "install.completed",
|
||||
"install.started": "install.started",
|
||||
"interaction.created": "interaction.created",
|
||||
"interaction.resolved": "interaction.resolved",
|
||||
"project.created": "project.created",
|
||||
"routine.created": "routine.created",
|
||||
|
|
@ -233,12 +242,23 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = {
|
|||
"other": "Fallback when the adapter type is unknown or not represented by the tracked enum."
|
||||
}
|
||||
},
|
||||
"interaction.created": {
|
||||
"interaction_kind": {
|
||||
"suggest_tasks": "Board-facing interaction that proposes concrete subtasks for acceptance.",
|
||||
"ask_user_questions": "Board-facing interaction that asks structured questions and stores answers.",
|
||||
"request_confirmation": "Board-facing interaction that asks for a single accept or reject decision.",
|
||||
"request_checkbox_confirmation": "Board-facing interaction that asks the board to select options and confirm.",
|
||||
"request_item_verdicts": "Board-facing interaction that collects a verdict for each known item.",
|
||||
"other": "Fallback when the interaction kind is unknown or not represented by the tracked enum."
|
||||
}
|
||||
},
|
||||
"interaction.resolved": {
|
||||
"interaction_kind": {
|
||||
"suggest_tasks": "Board-facing interaction that proposes concrete subtasks for acceptance.",
|
||||
"ask_user_questions": "Board-facing interaction that asks structured questions and stores answers.",
|
||||
"request_confirmation": "Board-facing interaction that asks for a single accept or reject decision.",
|
||||
"request_checkbox_confirmation": "Board-facing interaction that asks the board to select options and confirm.",
|
||||
"request_item_verdicts": "Board-facing interaction that collects a verdict for each known item.",
|
||||
"other": "Fallback when the interaction kind is unknown or not represented by the tracked enum."
|
||||
},
|
||||
"status": {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export {
|
|||
trackAgentFirstHeartbeat,
|
||||
trackAgentTaskCompleted,
|
||||
trackErrorHandlerCrash,
|
||||
trackInteractionCreated,
|
||||
trackInteractionResolved,
|
||||
} from "./events.js";
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -18,4 +18,9 @@ describe("telemetry retention contract", () => {
|
|||
expect(cls).toBeDefined();
|
||||
expect(RETENTION_DAYS[cls!]).toBe(90);
|
||||
});
|
||||
|
||||
it("interaction compatibility telemetry uses enum/count retention", () => {
|
||||
expect(EVENT_RETENTION_CLASS["interaction.created"]).toBe("operational_enum_count");
|
||||
expect(EVENT_RETENTION_CLASS["interaction.resolved"]).toBe("operational_enum_count");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,4 +45,6 @@ export type RetentionClass = keyof typeof RETENTION_DAYS;
|
|||
*/
|
||||
export const EVENT_RETENTION_CLASS: Partial<Record<string, RetentionClass>> = {
|
||||
"codex.credential_health": "operational_enum_count",
|
||||
"interaction.created": "operational_enum_count",
|
||||
"interaction.resolved": "operational_enum_count",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import type {
|
||||
IssueThreadInteractionCanonicalResolverPolicy,
|
||||
IssueThreadInteractionEffectiveResolverPolicySource,
|
||||
IssueThreadInteractionResolverPolicyProvenance,
|
||||
} from "../constants.js";
|
||||
import type { InboxDismissalKind } from "./inbox-dismissal.js";
|
||||
|
||||
export const ATTENTION_SOURCE_KINDS = [
|
||||
|
|
@ -180,6 +185,36 @@ export type AttentionItemDetail =
|
|||
images: AttentionDetailImage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Who may resolve an issue-thread interaction, as the server evaluated it
|
||||
* (PAP-17287). A collapsed attention row carries decision buttons before the
|
||||
* full interaction is ever fetched, so the audience has to travel with the feed
|
||||
* item — otherwise the queue asks for a decision without saying whose it is.
|
||||
*
|
||||
* These are *facts*, not copy: the canonical policy the resolution evaluator
|
||||
* will apply plus the identities it will compare against. Presentation layers
|
||||
* turn them into a sentence; nothing here grants or withholds capability, which
|
||||
* the server re-checks at use time.
|
||||
*/
|
||||
export interface AttentionResolverAudience {
|
||||
/** Canonical policy the creator asked for, before caps and clamps. */
|
||||
requestedResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
/** Canonical policy the server will actually enforce. */
|
||||
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
/** Why the effective policy differs from the requested one, if it does. */
|
||||
effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource;
|
||||
/** Whether the requested policy was explicit, inherited, or pre-migration. */
|
||||
resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance;
|
||||
/** Agent the card is addressed to, when it names one. */
|
||||
addresseeAgentId: string | null;
|
||||
/** Display name of {@link addresseeAgentId}, resolved server-side. */
|
||||
addresseeName: string | null;
|
||||
/** Agent that created the card, excluded when the policy is `not_creator`. */
|
||||
createdByAgentId: string | null;
|
||||
/** Display name of {@link createdByAgentId}, resolved server-side. */
|
||||
createdByAgentName: string | null;
|
||||
}
|
||||
|
||||
export interface AttentionItem {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
|
@ -215,6 +250,11 @@ export interface AttentionItem {
|
|||
snoozedUntil: string | null;
|
||||
detail: AttentionItemDetail | null;
|
||||
trainingExampleId: string | null;
|
||||
/**
|
||||
* Set for `issue_thread_interaction` rows only. Absent on every other source
|
||||
* kind, whose decisions are not governed by a resolver policy.
|
||||
*/
|
||||
resolverAudience?: AttentionResolverAudience | null;
|
||||
}
|
||||
|
||||
export interface AttentionFeed {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type {
|
|||
AttentionFeedQuery,
|
||||
AttentionProjectRef,
|
||||
AttentionQueueRef,
|
||||
AttentionResolverAudience,
|
||||
AttentionSeverity,
|
||||
AttentionSortMode,
|
||||
AttentionSourceKind,
|
||||
|
|
|
|||
|
|
@ -25,8 +25,12 @@ import type {
|
|||
IssueWorkMode,
|
||||
ModelProfileKey,
|
||||
IssueThreadInteractionContinuationPolicy,
|
||||
IssueThreadInteractionCanonicalResolverPolicy,
|
||||
IssueThreadInteractionEffectiveResolverPolicySource,
|
||||
IssueThreadInteractionKind,
|
||||
IssueThreadInteractionLegacyResolverPolicyAlias,
|
||||
IssueThreadInteractionResolverPolicy,
|
||||
IssueThreadInteractionResolverPolicyProvenance,
|
||||
IssueThreadInteractionStatus,
|
||||
IssueStatus,
|
||||
} from "../constants.js";
|
||||
|
|
@ -1295,7 +1299,9 @@ export interface RequestItemVerdictsResultItem {
|
|||
id: string;
|
||||
verdict: RequestItemVerdictValue;
|
||||
reason?: string | null;
|
||||
resolvedByUserId: string;
|
||||
resolvedByUserId?: string | null;
|
||||
resolvedByAgentId?: string | null;
|
||||
resolvedByRunId?: string | null;
|
||||
resolvedAt: Date | string;
|
||||
commentId?: string | null;
|
||||
}
|
||||
|
|
@ -1323,9 +1329,16 @@ export interface IssueThreadInteractionBase extends IssueThreadInteractionActorF
|
|||
summary?: string | null;
|
||||
status: IssueThreadInteractionStatus;
|
||||
continuationPolicy: IssueThreadInteractionContinuationPolicy;
|
||||
resolverPolicy: IssueThreadInteractionResolverPolicy;
|
||||
requestedResolverPolicy: IssueThreadInteractionResolverPolicy;
|
||||
effectiveResolverPolicy: IssueThreadInteractionResolverPolicy;
|
||||
/** @deprecated Read requestedResolverPolicy. Kept for API compatibility. */
|
||||
resolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
requestedResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance;
|
||||
effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource;
|
||||
legacyResolverPolicyAliases: {
|
||||
requested: IssueThreadInteractionLegacyResolverPolicyAlias | null;
|
||||
effective: IssueThreadInteractionLegacyResolverPolicyAlias | null;
|
||||
};
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
resolvedAt?: Date | string | null;
|
||||
|
|
|
|||
|
|
@ -426,6 +426,10 @@ export {
|
|||
addIssueCommentSchema,
|
||||
issueThreadInteractionStatusSchema,
|
||||
issueThreadInteractionKindSchema,
|
||||
issueThreadInteractionCanonicalResolverPolicySchema,
|
||||
issueThreadInteractionResolverPolicySchema,
|
||||
issueThreadInteractionResolverPolicyProvenanceSchema,
|
||||
issueThreadInteractionEffectiveResolverPolicySourceSchema,
|
||||
issueThreadInteractionContinuationPolicySchema,
|
||||
suggestedTaskDraftSchema,
|
||||
suggestTasksPayloadSchema,
|
||||
|
|
|
|||
|
|
@ -25,8 +25,11 @@ import {
|
|||
clampIssueRequestDepth,
|
||||
ISSUE_STATUSES,
|
||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||
ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES,
|
||||
ISSUE_THREAD_INTERACTION_EFFECTIVE_RESOLVER_POLICY_SOURCES,
|
||||
ISSUE_THREAD_INTERACTION_KINDS,
|
||||
ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES,
|
||||
ISSUE_THREAD_INTERACTION_RESOLVER_POLICY_PROVENANCES,
|
||||
ISSUE_THREAD_INTERACTION_STATUSES,
|
||||
ISSUE_WATCHDOG_DISCOVERY_KINDS,
|
||||
MODEL_PROFILE_KEYS,
|
||||
|
|
@ -692,7 +695,18 @@ export type AddIssueComment = z.infer<typeof addIssueCommentSchema>;
|
|||
|
||||
export const issueThreadInteractionStatusSchema = z.enum(ISSUE_THREAD_INTERACTION_STATUSES);
|
||||
export const issueThreadInteractionKindSchema = z.enum(ISSUE_THREAD_INTERACTION_KINDS);
|
||||
export const issueThreadInteractionResolverPolicySchema = z.enum(ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES);
|
||||
export const issueThreadInteractionCanonicalResolverPolicySchema = z
|
||||
.enum(ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES)
|
||||
.describe("Canonical resolver audience: anyone, not_creator, or human_only.");
|
||||
export const issueThreadInteractionResolverPolicySchema = z
|
||||
.enum(ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES)
|
||||
.describe(
|
||||
"Resolver audience. Use anyone, not_creator, or human_only; board_or_agents and board_only are deprecated compatibility aliases.",
|
||||
);
|
||||
export const issueThreadInteractionResolverPolicyProvenanceSchema = z
|
||||
.enum(ISSUE_THREAD_INTERACTION_RESOLVER_POLICY_PROVENANCES);
|
||||
export const issueThreadInteractionEffectiveResolverPolicySourceSchema = z
|
||||
.enum(ISSUE_THREAD_INTERACTION_EFFECTIVE_RESOLVER_POLICY_SOURCES);
|
||||
export const issueThreadInteractionContinuationPolicySchema = z.enum(
|
||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||
);
|
||||
|
|
@ -1165,9 +1179,26 @@ export const requestItemVerdictsResultItemSchema = z.object({
|
|||
id: z.string().trim().min(1).max(120),
|
||||
verdict: requestItemVerdictValueSchema,
|
||||
reason: z.string().trim().max(4000).nullable().optional(),
|
||||
resolvedByUserId: z.string().trim().min(1).max(255),
|
||||
resolvedByUserId: z.string().trim().min(1).max(255).nullable().optional(),
|
||||
resolvedByAgentId: z.string().uuid().nullable().optional(),
|
||||
resolvedByRunId: z.string().uuid().nullable().optional(),
|
||||
resolvedAt: z.union([z.string().datetime(), z.date()]),
|
||||
commentId: z.string().uuid().nullable().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.resolvedByUserId && !value.resolvedByAgentId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "a user or agent resolver is required",
|
||||
path: ["resolvedByUserId"],
|
||||
});
|
||||
}
|
||||
if (value.resolvedByAgentId && !value.resolvedByRunId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "resolvedByRunId is required for an agent resolver",
|
||||
path: ["resolvedByRunId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const requestItemVerdictsResultSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -798,6 +798,101 @@ describeEmbeddedPostgres("attention service", () => {
|
|||
expect(interactionTitles).not.toContain("Active reviewer question");
|
||||
});
|
||||
|
||||
// PAP-17287: a collapsed queue row offers Accept/Reject before anything fetches
|
||||
// the interaction, so the audience the resolution routes will enforce has to
|
||||
// ship with the feed item.
|
||||
it("ships the effective resolver audience with each interaction row", async () => {
|
||||
const { companyId, workerId, reviewerId } = await seedCompany("ARA");
|
||||
const issueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ARA-1",
|
||||
title: "Needs a decision",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
title: "Open question",
|
||||
createdByAgentId: workerId,
|
||||
payload: { version: 1, questions: [] },
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
title: "Creator-excluded question",
|
||||
createdByAgentId: workerId,
|
||||
requestedResolverPolicy: "not_creator",
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
payload: { version: 1, questions: [] },
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
title: "Capped question",
|
||||
createdByAgentId: workerId,
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
effectiveResolverPolicySource: "company_cap",
|
||||
payload: { version: 1, questions: [] },
|
||||
},
|
||||
]);
|
||||
// An addressed card only reaches board attention once its addressee cannot
|
||||
// act, which is exactly when naming the addressee matters most.
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
title: "Addressed question",
|
||||
createdByAgentId: workerId,
|
||||
addresseeAgentId: reviewerId,
|
||||
payload: { version: 1, questions: [] },
|
||||
});
|
||||
await agentService(db).pause(reviewerId);
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
const audienceByTitle = new Map(feed.items
|
||||
.filter((item) => item.sourceKind === "issue_thread_interaction")
|
||||
.map((item) => [item.subject.title, item.resolverAudience]));
|
||||
|
||||
expect(audienceByTitle.get("Open question")).toMatchObject({
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
addresseeAgentId: null,
|
||||
addresseeName: null,
|
||||
});
|
||||
expect(audienceByTitle.get("Creator-excluded question")).toMatchObject({
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
createdByAgentId: workerId,
|
||||
createdByAgentName: "Worker",
|
||||
});
|
||||
expect(audienceByTitle.get("Capped question")).toMatchObject({
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
effectiveResolverPolicySource: "company_cap",
|
||||
});
|
||||
expect(audienceByTitle.get("Addressed question")).toMatchObject({
|
||||
addresseeAgentId: reviewerId,
|
||||
addresseeName: "Reviewer",
|
||||
});
|
||||
// Non-interaction rows carry no resolver policy at all.
|
||||
expect(feed.items.find((item) => item.sourceKind !== "issue_thread_interaction")?.resolverAudience)
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses failed-run attention after a newer run for the same issue", async () => {
|
||||
const { companyId, workerId } = await seedCompany("ATN");
|
||||
const issueId = await insertIssue({
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ describeEmbeddedPostgres("cross-issue influence limit PostgreSQL serialization",
|
|||
contextSnapshot: { issueId: sourceIssueId },
|
||||
});
|
||||
await db.insert(activityLog).values(
|
||||
Array.from({ length: 19 }, () => ({
|
||||
Array.from({ length: 18 }, () => ({
|
||||
companyId,
|
||||
actorType: "agent" as const,
|
||||
actorId: agentId,
|
||||
|
|
@ -93,13 +93,18 @@ describeEmbeddedPostgres("cross-issue influence limit PostgreSQL serialization",
|
|||
kind: "comment" as const,
|
||||
now: CROSS_ISSUE_INFLUENCE_ENFORCE_AT,
|
||||
};
|
||||
// A comment, a PATCH, and an issue-thread interaction resolution race for the
|
||||
// last slot of the shared budget: the row lock must let exactly one of 19/20
|
||||
// through per attempt and fail the twenty-first closed.
|
||||
const decisions = await Promise.all([
|
||||
observeCrossIssueInfluence(db, input),
|
||||
observeCrossIssueInfluence(db, { ...input, kind: "update" }),
|
||||
observeCrossIssueInfluence(db, { ...input, kind: "interaction_resolution" }),
|
||||
]);
|
||||
|
||||
expect(decisions.map((decision) => decision?.allowed).sort()).toEqual([false, true]);
|
||||
expect(decisions.map((decision) => decision?.count).sort((a, b) => Number(a) - Number(b))).toEqual([20, 21]);
|
||||
expect(decisions.map((decision) => decision?.allowed).sort()).toEqual([false, true, true]);
|
||||
expect(decisions.map((decision) => decision?.count).sort((a, b) => Number(a) - Number(b)))
|
||||
.toEqual([19, 20, 21]);
|
||||
|
||||
const recorded = await db
|
||||
.select({ action: activityLog.action })
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ describe("cross-issue influence limit rollout", () => {
|
|||
expect(capError.details.whoCanAct).toContain("Fable");
|
||||
});
|
||||
|
||||
it("uses one durable counter for cross-issue comments and PATCH updates", async () => {
|
||||
it("uses one durable counter for cross-issue comments, PATCH updates, and interaction resolutions", async () => {
|
||||
const fake = counterDb();
|
||||
const base = {
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
|
|
@ -120,9 +120,32 @@ describe("cross-issue influence limit rollout", () => {
|
|||
.resolves.toMatchObject({ count: 1, allowed: true });
|
||||
await expect(observeCrossIssueInfluence(fake.db as never, { ...base, kind: "update" }))
|
||||
.resolves.toMatchObject({ count: 2, allowed: true });
|
||||
await expect(observeCrossIssueInfluence(fake.db as never, { ...base, kind: "interaction_resolution" }))
|
||||
.resolves.toMatchObject({ count: 3, allowed: true });
|
||||
|
||||
expect(fake.observedCount).toBe(2);
|
||||
expect(fake.inserted.map((row) => (row.details as { kind: string }).kind)).toEqual(["comment", "update"]);
|
||||
expect(fake.observedCount).toBe(3);
|
||||
expect(fake.inserted.map((row) => (row.details as { kind: string }).kind))
|
||||
.toEqual(["comment", "update", "interaction_resolution"]);
|
||||
});
|
||||
|
||||
it("counts an interaction resolution against a budget already spent on comments", async () => {
|
||||
const fake = counterDb(CROSS_ISSUE_INFLUENCE_LIMIT);
|
||||
|
||||
await expect(observeCrossIssueInfluence(fake.db as never, {
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
runId: "11111111-1111-4111-8111-111111111111",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
targetIssueId: "55555555-5555-4555-8555-555555555555",
|
||||
kind: "interaction_resolution",
|
||||
now: CROSS_ISSUE_INFLUENCE_ENFORCE_AT,
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
mode: "enforce",
|
||||
count: CROSS_ISSUE_INFLUENCE_LIMIT + 1,
|
||||
});
|
||||
expect(fake.inserted).toEqual([
|
||||
expect.objectContaining({ action: "issue.cross_issue_influence_cap_rejected" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not count same-issue writes", async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"capturedAt": "2026-08-14T13:00:00.000Z",
|
||||
"purpose": "Immutable regression evidence for the stop-time authorization shape observed by the PAP-17159 watchdog. This fixture is historical evidence; do not use it to rewrite or re-resolve the source interaction.",
|
||||
"companyId": "5cbe79ee-acb3-4597-896e-7662742593cd",
|
||||
"watchdogRun": {
|
||||
"id": "3ede42c3-efed-4658-9cff-eda1b5c08e6d",
|
||||
"status": "succeeded",
|
||||
"watchdogIssueId": "99c136ef-c48e-45ec-8854-22b7eb5cd512",
|
||||
"watchdogIssueIdentifier": "PAP-17159",
|
||||
"watchedIssueId": "80f77c6d-87b5-4585-b1a6-eaf2b68667de",
|
||||
"watchedIssueIdentifier": "PAP-17100",
|
||||
"stopFingerprint": "task_watchdog_stop:427b917d8175df5b4fc657e2f568c03fe8785fa7fd4f30b99d277d0b6f8970f2",
|
||||
"startedAt": "2026-08-13T16:31:00.016Z",
|
||||
"finishedAt": "2026-08-13T16:34:39.713Z"
|
||||
},
|
||||
"creatorRun": {
|
||||
"id": "ec65375d-e43f-4f1c-808a-7a953477b5aa",
|
||||
"status": "succeeded",
|
||||
"issueId": "1c2d9fb4-f9d1-4dc3-baaa-7fc5971b52d4",
|
||||
"issueIdentifier": "PAP-17237",
|
||||
"startedAt": "2026-08-13T16:13:09.140Z",
|
||||
"finishedAt": "2026-08-13T16:30:32.511Z"
|
||||
},
|
||||
"interactionAtStop": {
|
||||
"id": "8bef17ef-6df4-4e52-8333-f3a789580be0",
|
||||
"issueId": "1c2d9fb4-f9d1-4dc3-baaa-7fc5971b52d4",
|
||||
"kind": "request_confirmation",
|
||||
"status": "pending",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"requestedResolverPolicy": "board_only",
|
||||
"effectiveResolverPolicy": "board_only",
|
||||
"sourceRunId": "ec65375d-e43f-4f1c-808a-7a953477b5aa",
|
||||
"createdByAgentId": "352aa64b-2a22-4d00-928f-71f5fb884f66",
|
||||
"createdAt": "2026-08-13T16:30:24.126Z",
|
||||
"target": {
|
||||
"type": "issue_document",
|
||||
"issueId": "1c2d9fb4-f9d1-4dc3-baaa-7fc5971b52d4",
|
||||
"documentId": "b2066808-597e-4221-b41d-4065842a1a27",
|
||||
"key": "plan",
|
||||
"revisionId": "30821754-4bc9-4562-805f-e6ec04064449",
|
||||
"revisionNumber": 1
|
||||
}
|
||||
},
|
||||
"watchdogAuthorizationAtStop": {
|
||||
"allowedOperations": [
|
||||
"resolve_eligible_request_confirmation_plan_interactions"
|
||||
],
|
||||
"deniedOperations": [
|
||||
"resolve_board_only_or_security_sensitive_approvals"
|
||||
]
|
||||
},
|
||||
"stopSnapshot": {
|
||||
"version": 2,
|
||||
"materialLeaves": [
|
||||
{
|
||||
"issueId": "1c2d9fb4-f9d1-4dc3-baaa-7fc5971b52d4",
|
||||
"status": "in_review",
|
||||
"assigneeAgentId": "352aa64b-2a22-4d00-928f-71f5fb884f66",
|
||||
"assigneeUserId": null,
|
||||
"blockerIssueIds": [],
|
||||
"pendingInteractionIds": ["8bef17ef-6df4-4e52-8333-f3a789580be0"],
|
||||
"pendingApprovalIds": []
|
||||
},
|
||||
{
|
||||
"issueId": "56c974ca-a506-4088-aa63-3b833c726094",
|
||||
"status": "blocked",
|
||||
"assigneeAgentId": "eae27ade-c807-4723-97e8-23808ad4fa14",
|
||||
"assigneeUserId": null,
|
||||
"blockerIssueIds": ["6d1707c9-2c37-4eb4-9cb5-1faf17f194e2"],
|
||||
"pendingInteractionIds": [],
|
||||
"pendingApprovalIds": []
|
||||
},
|
||||
{
|
||||
"issueId": "6d1707c9-2c37-4eb4-9cb5-1faf17f194e2",
|
||||
"status": "blocked",
|
||||
"assigneeAgentId": "6176c74d-1b42-4f04-8aed-8af2f4cbe51f",
|
||||
"assigneeUserId": null,
|
||||
"blockerIssueIds": ["bebf57a9-1ad4-4e63-a454-ae6f28d14a99"],
|
||||
"pendingInteractionIds": [],
|
||||
"pendingApprovalIds": []
|
||||
}
|
||||
],
|
||||
"waitsByIssueId": {
|
||||
"1c2d9fb4-f9d1-4dc3-baaa-7fc5971b52d4": {
|
||||
"pendingInteractionIds": ["8bef17ef-6df4-4e52-8333-f3a789580be0"],
|
||||
"pendingApprovalIds": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"manualAcceptance": {
|
||||
"status": "accepted",
|
||||
"resolverPolicy": "board_only",
|
||||
"resolvedByUserId": "1gQsX9Jbj5jk1Wu51YU1G0atxCRwj1Om",
|
||||
"resolvedByAgentId": null,
|
||||
"resolvedByRunId": null,
|
||||
"resolvedAt": "2026-08-14T11:59:10.773Z",
|
||||
"result": { "version": 1, "outcome": "accepted" }
|
||||
},
|
||||
"descendantPathAtVerification": [
|
||||
{ "identifier": "PAP-17159", "id": "99c136ef-c48e-45ec-8854-22b7eb5cd512", "relation": "watchdog_for", "status": "done" },
|
||||
{ "identifier": "PAP-17100", "id": "80f77c6d-87b5-4585-b1a6-eaf2b68667de", "relation": "watched_root", "status": "blocked" },
|
||||
{ "identifier": "PAP-17104", "id": "bebf57a9-1ad4-4e63-a454-ae6f28d14a99", "relation": "child", "status": "blocked" },
|
||||
{ "identifier": "PAP-17237", "id": "1c2d9fb4-f9d1-4dc3-baaa-7fc5971b52d4", "relation": "child", "status": "done" }
|
||||
],
|
||||
"verification": {
|
||||
"historicalInteractionStillPending": false,
|
||||
"historicalInteractionIsCurrentWait": false,
|
||||
"auditAttributionChanged": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { CROSS_ISSUE_INFLUENCE_LIMIT } from "../services/cross-issue-influence-limit.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres interaction-resolution cap tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-level proof for the per-run cross-issue influence cap on issue-thread
|
||||
* interaction resolution, against the real routes, real services, and a real
|
||||
* database: the mocked route harness can prove ordering, but only this can prove
|
||||
* that a capped run leaves no interaction result, child task, activity receipt,
|
||||
* or wake behind, and that concurrent resolutions cannot race past the cap.
|
||||
*/
|
||||
describeEmbeddedPostgres("cross-issue interaction resolution cap (routes + postgres)", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-interaction-cap-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 30_000);
|
||||
|
||||
// An allowed resolution wakes the assignee, and that wake can land a heartbeat
|
||||
// run row just after the response, so teardown is best-effort in foreign-key
|
||||
// order. Every assertion below is scoped to its own seeded company instead of
|
||||
// relying on an empty database.
|
||||
afterEach(async () => {
|
||||
const cleanups = [
|
||||
() => db.delete(issueThreadInteractions),
|
||||
() => db.delete(activityLog),
|
||||
() => db.delete(heartbeatRuns),
|
||||
() => db.delete(agentWakeupRequests),
|
||||
() => db.delete(heartbeatRuns),
|
||||
() => db.delete(issues),
|
||||
() => db.delete(companyMemberships),
|
||||
() => db.delete(agents),
|
||||
() => db.delete(companies),
|
||||
];
|
||||
for (const cleanup of cleanups) await cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
function app(actor: Record<string, unknown>) {
|
||||
const testApp = express();
|
||||
testApp.use(express.json());
|
||||
testApp.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
testApp.use("/api", issueRoutes(db, {} as any, {}));
|
||||
testApp.use(errorHandler);
|
||||
return testApp;
|
||||
}
|
||||
|
||||
function agentActor(companyId: string, agentId: string, runId: string) {
|
||||
return { type: "agent", source: "agent_key", companyId, agentId, runId };
|
||||
}
|
||||
|
||||
function boardActor(companyId: string, userId: string) {
|
||||
return {
|
||||
type: "board",
|
||||
source: "session",
|
||||
userId,
|
||||
companyIds: [companyId],
|
||||
memberships: [{ companyId, status: "active", membershipRole: "operator" }],
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
}
|
||||
|
||||
const INTERACTION_FIXTURES = {
|
||||
accept: {
|
||||
// A suggested-task card proves the 429 also stops the child-task effect.
|
||||
kind: "suggest_tasks",
|
||||
payload: {
|
||||
version: 1,
|
||||
tasks: [{ clientKey: "spawned", title: "Spawned by a capped run" }],
|
||||
},
|
||||
body: { selectedClientKeys: ["spawned"] },
|
||||
},
|
||||
reject: {
|
||||
kind: "request_confirmation",
|
||||
payload: { version: 1, prompt: "Proceed?" },
|
||||
body: { reason: "Not now" },
|
||||
},
|
||||
respond: {
|
||||
kind: "ask_user_questions",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [{
|
||||
id: "scope",
|
||||
prompt: "Which scope?",
|
||||
selectionMode: "single",
|
||||
options: [{ id: "phase-1", label: "Phase 1" }],
|
||||
}],
|
||||
},
|
||||
body: { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] },
|
||||
},
|
||||
verdicts: {
|
||||
kind: "request_item_verdicts",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review these",
|
||||
items: [{ id: "item-1", label: "Item one" }],
|
||||
verdicts: ["approve", "reject"],
|
||||
},
|
||||
body: { verdicts: [{ id: "item-1", verdict: "approve" }] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ResolutionRoute = keyof typeof INTERACTION_FIXTURES;
|
||||
|
||||
async function seedCompanyAndAgent(prefix: string) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const userId = `${prefix.toLowerCase()}-operator`;
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: `${prefix} Company`,
|
||||
issuePrefix: prefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: `${prefix} Resolver`,
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
});
|
||||
return { companyId, agentId, userId };
|
||||
}
|
||||
|
||||
let issueSequence = 0;
|
||||
|
||||
async function seedIssue(companyId: string, prefix: string, assigneeAgentId: string | null) {
|
||||
const issueId = randomUUID();
|
||||
issueSequence += 1;
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: `${prefix}-${issueSequence}`,
|
||||
title: `${prefix} issue ${issueSequence}`,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
});
|
||||
return issueId;
|
||||
}
|
||||
|
||||
/** A run whose persisted context names `sourceIssueId`, not the target issue. */
|
||||
async function seedRun(companyId: string, agentId: string, sourceIssueId: string) {
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: "running",
|
||||
contextSnapshot: { issueId: sourceIssueId, wakeReason: "issue_assigned" },
|
||||
});
|
||||
return runId;
|
||||
}
|
||||
|
||||
async function seedInteraction(companyId: string, issueId: string, route: ResolutionRoute) {
|
||||
const fixture = INTERACTION_FIXTURES[route];
|
||||
const [row] = await db.insert(issueThreadInteractions).values({
|
||||
companyId,
|
||||
issueId,
|
||||
kind: fixture.kind,
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
payload: fixture.payload as never,
|
||||
}).returning({ id: issueThreadInteractions.id });
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function spendBudget(companyId: string, agentId: string, runId: string, attempts: number) {
|
||||
if (attempts === 0) return;
|
||||
await db.insert(activityLog).values(Array.from({ length: attempts }, () => ({
|
||||
companyId,
|
||||
actorType: "agent" as const,
|
||||
actorId: agentId,
|
||||
agentId,
|
||||
runId,
|
||||
action: "issue.cross_issue_influence_observed",
|
||||
entityType: "issue",
|
||||
entityId: runId,
|
||||
})));
|
||||
}
|
||||
|
||||
async function countInfluenceRows(companyId: string, runId: string, action: string) {
|
||||
const rows = await db
|
||||
.select({ id: activityLog.id })
|
||||
.from(activityLog)
|
||||
.where(and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.runId, runId),
|
||||
eq(activityLog.action, action),
|
||||
));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
it.each(["accept", "reject", "respond", "verdicts"] as const)(
|
||||
"fails a capped run closed on cross-issue %s with no interaction, task, activity, or wake",
|
||||
async (route) => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent("CAP");
|
||||
const sourceIssueId = await seedIssue(companyId, "CAP", agentId);
|
||||
const targetIssueId = await seedIssue(companyId, "CAP", agentId);
|
||||
const runId = await seedRun(companyId, agentId, sourceIssueId);
|
||||
await spendBudget(companyId, agentId, runId, CROSS_ISSUE_INFLUENCE_LIMIT);
|
||||
const interactionId = await seedInteraction(companyId, targetIssueId, route);
|
||||
|
||||
const res = await request(app(agentActor(companyId, agentId, runId)))
|
||||
.post(`/api/issues/${targetIssueId}/interactions/${interactionId}/${route}`)
|
||||
.send(INTERACTION_FIXTURES[route].body);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(429);
|
||||
expect(res.body.details).toMatchObject({
|
||||
code: "cross_issue_influence_cap_exceeded",
|
||||
cap: CROSS_ISSUE_INFLUENCE_LIMIT,
|
||||
count: CROSS_ISSUE_INFLUENCE_LIMIT + 1,
|
||||
mode: "enforce",
|
||||
});
|
||||
// The refusal must not leak the run's own (possibly inaccessible) source
|
||||
// issue or the resolver policy it passed.
|
||||
expect(JSON.stringify(res.body)).not.toContain(sourceIssueId);
|
||||
expect(JSON.stringify(res.body)).not.toContain("anyone");
|
||||
|
||||
const [interaction] = await db
|
||||
.select({ status: issueThreadInteractions.status, result: issueThreadInteractions.result })
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId));
|
||||
expect(interaction).toMatchObject({ status: "pending", result: null });
|
||||
|
||||
const childIssues = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.parentId, targetIssueId)));
|
||||
expect(childIssues).toEqual([]);
|
||||
|
||||
const wakes = await db
|
||||
.select({ id: agentWakeupRequests.id })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.companyId, companyId));
|
||||
expect(wakes).toEqual([]);
|
||||
|
||||
const resolutionActivity = await db
|
||||
.select({ action: activityLog.action })
|
||||
.from(activityLog)
|
||||
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.entityId, targetIssueId)));
|
||||
expect(resolutionActivity.map((row) => row.action)).toEqual([
|
||||
"issue.cross_issue_influence_cap_rejected",
|
||||
]);
|
||||
expect(await countInfluenceRows(companyId, runId, "issue.cross_issue_influence_observed"))
|
||||
.toBe(CROSS_ISSUE_INFLUENCE_LIMIT);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("charges one budget slot for an allowed cross-issue resolution", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent("ALW");
|
||||
const sourceIssueId = await seedIssue(companyId, "ALW", agentId);
|
||||
const targetIssueId = await seedIssue(companyId, "ALW", agentId);
|
||||
const runId = await seedRun(companyId, agentId, sourceIssueId);
|
||||
const interactionId = await seedInteraction(companyId, targetIssueId, "respond");
|
||||
|
||||
const res = await request(app(agentActor(companyId, agentId, runId)))
|
||||
.post(`/api/issues/${targetIssueId}/interactions/${interactionId}/respond`)
|
||||
.send(INTERACTION_FIXTURES.respond.body);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({ status: "answered" });
|
||||
expect(await countInfluenceRows(companyId, runId, "issue.cross_issue_influence_observed")).toBe(1);
|
||||
|
||||
const [observed] = await db
|
||||
.select({ details: activityLog.details })
|
||||
.from(activityLog)
|
||||
.where(and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.action, "issue.cross_issue_influence_observed"),
|
||||
));
|
||||
expect(observed.details).toMatchObject({
|
||||
kind: "interaction_resolution",
|
||||
sourceIssueId,
|
||||
targetIssueId,
|
||||
count: 1,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("leaves same-issue and board resolutions outside the counter", async () => {
|
||||
const { companyId, agentId, userId } = await seedCompanyAndAgent("SME");
|
||||
const issueId = await seedIssue(companyId, "SME", agentId);
|
||||
const boardIssueId = await seedIssue(companyId, "SME", agentId);
|
||||
const runId = await seedRun(companyId, agentId, issueId);
|
||||
await spendBudget(companyId, agentId, runId, CROSS_ISSUE_INFLUENCE_LIMIT);
|
||||
const sameIssueInteractionId = await seedInteraction(companyId, issueId, "respond");
|
||||
const boardInteractionId = await seedInteraction(companyId, boardIssueId, "respond");
|
||||
|
||||
const sameIssue = await request(app(agentActor(companyId, agentId, runId)))
|
||||
.post(`/api/issues/${issueId}/interactions/${sameIssueInteractionId}/respond`)
|
||||
.send(INTERACTION_FIXTURES.respond.body);
|
||||
expect(sameIssue.status, JSON.stringify(sameIssue.body)).toBe(200);
|
||||
|
||||
const board = await request(app(boardActor(companyId, userId)))
|
||||
.post(`/api/issues/${boardIssueId}/interactions/${boardInteractionId}/respond`)
|
||||
.send(INTERACTION_FIXTURES.respond.body);
|
||||
expect(board.status, JSON.stringify(board.body)).toBe(200);
|
||||
|
||||
expect(await countInfluenceRows(companyId, runId, "issue.cross_issue_influence_observed"))
|
||||
.toBe(CROSS_ISSUE_INFLUENCE_LIMIT);
|
||||
expect(await countInfluenceRows(companyId, runId, "issue.cross_issue_influence_cap_rejected")).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
it("cannot let concurrent cross-issue resolutions race past the cap", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent("RCE");
|
||||
const sourceIssueId = await seedIssue(companyId, "RCE", agentId);
|
||||
const firstTargetId = await seedIssue(companyId, "RCE", agentId);
|
||||
const secondTargetId = await seedIssue(companyId, "RCE", agentId);
|
||||
const runId = await seedRun(companyId, agentId, sourceIssueId);
|
||||
// One slot left in the shared budget for two simultaneous resolutions.
|
||||
await spendBudget(companyId, agentId, runId, CROSS_ISSUE_INFLUENCE_LIMIT - 1);
|
||||
const firstInteractionId = await seedInteraction(companyId, firstTargetId, "respond");
|
||||
const secondInteractionId = await seedInteraction(companyId, secondTargetId, "respond");
|
||||
const client = app(agentActor(companyId, agentId, runId));
|
||||
|
||||
const results = await Promise.all([
|
||||
request(client)
|
||||
.post(`/api/issues/${firstTargetId}/interactions/${firstInteractionId}/respond`)
|
||||
.send(INTERACTION_FIXTURES.respond.body),
|
||||
request(client)
|
||||
.post(`/api/issues/${secondTargetId}/interactions/${secondInteractionId}/respond`)
|
||||
.send(INTERACTION_FIXTURES.respond.body),
|
||||
]);
|
||||
|
||||
expect(results.map((res) => res.status).sort()).toEqual([200, 429]);
|
||||
expect(await countInfluenceRows(companyId, runId, "issue.cross_issue_influence_observed"))
|
||||
.toBe(CROSS_ISSUE_INFLUENCE_LIMIT);
|
||||
expect(await countInfluenceRows(companyId, runId, "issue.cross_issue_influence_cap_rejected")).toBe(1);
|
||||
|
||||
const answered = await db
|
||||
.select({ status: issueThreadInteractions.status })
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.companyId, companyId));
|
||||
expect(answered.map((row) => row.status).sort()).toEqual(["answered", "pending"]);
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -138,6 +138,7 @@ describeEmbeddedPostgres("issue review attention", () => {
|
|||
it("reports every healthy review path as covered", async () => {
|
||||
const { companyId, agentId } = await seed();
|
||||
const interactionIssueId = await insertReview({ companyId, agentId, identifier: "RVA-2" });
|
||||
const humanOnlyInteractionIssueId = await insertReview({ companyId, agentId, identifier: "RVA-2H" });
|
||||
const approvalIssueId = await insertReview({ companyId, agentId, identifier: "RVA-3" });
|
||||
const monitorIssueId = await insertReview({
|
||||
companyId,
|
||||
|
|
@ -169,6 +170,18 @@ describeEmbeddedPostgres("issue review attention", () => {
|
|||
continuationPolicy: "wake_assignee",
|
||||
payload: { version: 1, prompt: "Approve?" },
|
||||
});
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId,
|
||||
issueId: humanOnlyInteractionIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
payload: { version: 1, prompt: "Human review?" },
|
||||
});
|
||||
const approvalId = randomUUID();
|
||||
await db.insert(approvals).values({
|
||||
id: approvalId,
|
||||
|
|
@ -201,6 +214,7 @@ describeEmbeddedPostgres("issue review attention", () => {
|
|||
const byId = new Map(rows.map((row) => [row.id, row.reviewAttention]));
|
||||
const expectedKinds = new Map([
|
||||
[interactionIssueId, "interaction"],
|
||||
[humanOnlyInteractionIssueId, "interaction"],
|
||||
[approvalIssueId, "approval"],
|
||||
[monitorIssueId, "monitor"],
|
||||
[humanIssueId, "human_reviewer"],
|
||||
|
|
@ -215,6 +229,12 @@ describeEmbeddedPostgres("issue review attention", () => {
|
|||
paths: expect.arrayContaining([expect.objectContaining({ kind })]),
|
||||
});
|
||||
}
|
||||
expect(byId.get(interactionIssueId)?.paths).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "interaction", responder: "Review Agent" }),
|
||||
]));
|
||||
expect(byId.get(humanOnlyInteractionIssueId)?.paths).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "interaction", responder: "Board" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("does not let a transiently skipped recovery consume its fingerprint", async () => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -123,7 +123,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
});
|
||||
}
|
||||
|
||||
it("persists addressees without allowing them to bypass board-only governance", async () => {
|
||||
it("persists addressees without allowing them to bypass human-only governance", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("Agent-addressed interaction");
|
||||
const creatorAgentId = randomUUID();
|
||||
const addresseeAgentId = randomUUID();
|
||||
|
|
@ -186,8 +186,10 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
);
|
||||
expect(created).toMatchObject({
|
||||
addresseeAgentId,
|
||||
requestedResolverPolicy: "board_or_agents",
|
||||
effectiveResolverPolicy: "board_or_agents",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
});
|
||||
|
||||
const answered = await interactionsSvc.answerQuestions(
|
||||
|
|
@ -234,7 +236,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
{ agentId: addresseeAgentId, runId: addresseeRunId },
|
||||
)).rejects.toMatchObject({
|
||||
status: 403,
|
||||
message: expect.stringContaining("board-only"),
|
||||
message: expect.stringContaining("human-only"),
|
||||
});
|
||||
|
||||
await expect(interactionsSvc.create(
|
||||
|
|
@ -1707,6 +1709,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
const created = await interactionsSvc.create({ id: issueId, companyId }, {
|
||||
kind: "request_confirmation",
|
||||
payload: { version: 1, prompt: "Approve this review?" },
|
||||
resolverPolicy: "anyone",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
|
@ -1720,7 +1723,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
}, created.id, {}, {
|
||||
agentId: resolverAgentId,
|
||||
runId: resolverRunId,
|
||||
reviewVerdictAuthorized: true,
|
||||
resolverPolicyRestriction: "anyone",
|
||||
});
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
|
|
@ -1861,6 +1864,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
const createdByResolver = await interactionsSvc.create({ id: issueId, companyId }, {
|
||||
kind: "request_confirmation",
|
||||
payload: { version: 1, prompt: "Approve your own request?" },
|
||||
resolverPolicy: "anyone",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
|
@ -1876,6 +1880,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
prompt: "Approve the same run?",
|
||||
options: [{ id: "approve", label: "Approve" }],
|
||||
},
|
||||
resolverPolicy: "anyone",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
|
@ -1887,10 +1892,10 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
const actor = {
|
||||
agentId: resolverAgentId,
|
||||
runId: resolverRunId,
|
||||
reviewVerdictAuthorized: true,
|
||||
resolverPolicyRestriction: "not_creator",
|
||||
};
|
||||
await expect(interactionsSvc.acceptInteraction(issue, createdByResolver.id, {}, actor))
|
||||
.rejects.toThrow("Agents cannot resolve interactions they created");
|
||||
.rejects.toThrow("requires a resolver other than its creator or creating run");
|
||||
await db.update(activityLog).set({
|
||||
details: {
|
||||
status: "in_review",
|
||||
|
|
@ -1900,7 +1905,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
}).where(eq(activityLog.entityId, issueId));
|
||||
await expect(interactionsSvc.acceptInteraction(issue, createdBySameRun.id, {
|
||||
selectedOptionIds: ["approve"],
|
||||
}, actor)).rejects.toThrow("Agents cannot resolve interactions created by the same run");
|
||||
}, actor)).rejects.toThrow("requires a resolver other than its creator or creating run");
|
||||
});
|
||||
|
||||
it("accepts request_checkbox_confirmation interactions with selected option ids", async () => {
|
||||
|
|
@ -2960,16 +2965,20 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
latestRevisionNumber: 2,
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction({
|
||||
await expect(interactionsSvc.acceptInteraction({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
projectId: null,
|
||||
}, created.id, {}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { code: "interaction_stale_target" },
|
||||
});
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
const expired = await interactionsSvc.getForIssue({ id: issueId, companyId }, created.id);
|
||||
expect(expired).toMatchObject({
|
||||
id: created.id,
|
||||
status: "expired",
|
||||
payload: {
|
||||
|
|
@ -3221,17 +3230,20 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
latestRevisionNumber: 2,
|
||||
});
|
||||
|
||||
const stale = await interactionsSvc.submitItemVerdicts({
|
||||
await expect(interactionsSvc.submitItemVerdicts({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
verdicts: [{ id: "docs", verdict: "approve" }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { code: "interaction_stale_target" },
|
||||
});
|
||||
|
||||
expect(stale.newlyResolvedItemIds).toEqual([]);
|
||||
expect(stale.interaction).toMatchObject({
|
||||
const stale = await interactionsSvc.getForIssue({ id: issueId, companyId }, created.id);
|
||||
expect(stale).toMatchObject({
|
||||
id: created.id,
|
||||
status: "expired",
|
||||
payload: {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,40 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
|
|||
return calls.at(-1)?.[1] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function lastInteractionCreatedDimensions() {
|
||||
expect(telemetryMocks.track).toHaveBeenCalledWith("interaction.created", expect.any(Object));
|
||||
const calls = telemetryMocks.track.mock.calls.filter((call) => call[0] === "interaction.created");
|
||||
return calls.at(-1)?.[1] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("emits deprecated resolver alias use at creation without interaction content", async () => {
|
||||
const { companyId, issueId } = await seedIssue("Deprecated alias telemetry issue title");
|
||||
|
||||
await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
resolverPolicy: "board_only",
|
||||
title: "Private confirmation title",
|
||||
summary: "Private confirmation summary",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Private confirmation prompt",
|
||||
detailsMarkdown: "Private confirmation details",
|
||||
acceptLabel: "Private accept label",
|
||||
rejectLabel: "Private reject label",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(lastInteractionCreatedDimensions()).toEqual({
|
||||
interaction_kind: "request_confirmation",
|
||||
used_deprecated_resolver_policy_alias: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits accepted suggested-task telemetry with created and skipped task counts", async () => {
|
||||
const { companyId, goalId, issueId } = await seedIssue("Accept suggested tasks telemetry");
|
||||
|
||||
|
|
@ -286,7 +320,7 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
|
|||
expectNoRawInteractionIds(dimensions);
|
||||
});
|
||||
|
||||
it("emits answered question telemetry with system resolver and raw creator role", async () => {
|
||||
it("emits answered question telemetry with system resolver and legacy restriction signal", async () => {
|
||||
const { companyId, issueId } = await seedIssue("Answer question telemetry");
|
||||
const creatorAgentId = await seedAgent(companyId, "Wizard");
|
||||
|
||||
|
|
@ -319,7 +353,10 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
|
|||
});
|
||||
await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({ continuationPolicy: "" })
|
||||
.set({
|
||||
continuationPolicy: "",
|
||||
resolverPolicyProvenance: "legacy_inherited_restriction",
|
||||
})
|
||||
.where(eq(issueThreadInteractions.id, created.id));
|
||||
|
||||
await interactionsSvc.answerQuestions({
|
||||
|
|
@ -328,7 +365,9 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
|
|||
}, created.id, {
|
||||
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
|
||||
summaryMarkdown: "Do not emit this free text.",
|
||||
}, {});
|
||||
}, {
|
||||
systemId: "system:pr-merged",
|
||||
});
|
||||
|
||||
const dimensions = lastInteractionResolvedDimensions();
|
||||
expect(dimensions).toMatchObject({
|
||||
|
|
@ -340,10 +379,14 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
|
|||
target_type: "none",
|
||||
question_count: 2,
|
||||
answered_question_count: 1,
|
||||
legacy_inherited_restriction: true,
|
||||
});
|
||||
expect(dimensions).not.toHaveProperty("continuation_policy");
|
||||
expect(dimensions).not.toHaveProperty("summaryMarkdown");
|
||||
expect(dimensions).not.toHaveProperty("answers");
|
||||
expect(JSON.stringify(dimensions)).not.toContain("Choose the scope");
|
||||
expect(JSON.stringify(dimensions)).not.toContain("Phase 1");
|
||||
expect(JSON.stringify(dimensions)).not.toContain("Do not emit this free text.");
|
||||
expectNoRawInteractionIds(dimensions);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,20 @@ describe("openapi routes", () => {
|
|||
expect(
|
||||
res.body.paths["/api/issues/{id}/interactions/{interactionId}/withdraw"].post.summary,
|
||||
).toBe("Withdraw a pending issue thread interaction");
|
||||
const createInteraction = res.body.paths["/api/issues/{id}/interactions"].post;
|
||||
expect(createInteraction.description).toContain("defaults to canonical `anyone`");
|
||||
const createInteractionSchema = JSON.stringify(
|
||||
createInteraction.requestBody.content["application/json"].schema,
|
||||
);
|
||||
for (const resolverPolicy of [
|
||||
"anyone",
|
||||
"not_creator",
|
||||
"human_only",
|
||||
"board_or_agents",
|
||||
"board_only",
|
||||
]) {
|
||||
expect(createInteractionSchema).toContain(`\"${resolverPolicy}\"`);
|
||||
}
|
||||
expect(res.body.paths["/api/companies/{companyId}/folders/items/move"].post.summary).toBe(
|
||||
"Move an item into or out of a folder",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ describe("paperclip skill utils", () => {
|
|||
const apiReference = await fs.readFile(path.resolve("skills/paperclip/references/api-reference.md"), "utf8");
|
||||
const issueDocs = await fs.readFile(path.resolve("docs/api/issues.md"), "utf8");
|
||||
for (const body of [apiReference, issueDocs]) {
|
||||
expect(body).toContain('resolverPolicy: "board_only" | "board_or_agents"');
|
||||
expect(body).toContain('resolverPolicy: "anyone" | "not_creator" | "human_only"');
|
||||
expect(body).toContain("requestedResolverPolicy");
|
||||
expect(body).toContain("effectiveResolverPolicy");
|
||||
expect(body).toContain("toolAction");
|
||||
|
|
|
|||
|
|
@ -862,7 +862,14 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
|||
contextSnapshot: {},
|
||||
});
|
||||
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
|
||||
const services = buildHostServices(
|
||||
db,
|
||||
"plugin-record-id",
|
||||
"paperclip.gateway",
|
||||
createEventBusStub(),
|
||||
undefined,
|
||||
{ heartbeatRuntimeEnv: {} },
|
||||
);
|
||||
const comment = await services.issues.createComment({
|
||||
issueId,
|
||||
companyId,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
trackAgentCreated,
|
||||
trackAgentFirstHeartbeat,
|
||||
trackAgentTaskCompleted,
|
||||
trackInteractionCreated,
|
||||
trackInteractionResolved,
|
||||
trackInstallCompleted,
|
||||
} from "@paperclipai/shared/telemetry";
|
||||
|
|
@ -111,6 +112,7 @@ describe("shared telemetry agent events", () => {
|
|||
optionCount: 2,
|
||||
selectedOptionCount: 1,
|
||||
skippedTaskCount: 3,
|
||||
legacyInheritedRestriction: true,
|
||||
});
|
||||
|
||||
expect(client.track).toHaveBeenCalledWith("interaction.resolved", {
|
||||
|
|
@ -125,6 +127,21 @@ describe("shared telemetry agent events", () => {
|
|||
option_count: 2,
|
||||
selected_option_count: 1,
|
||||
skipped_task_count: 3,
|
||||
legacy_inherited_restriction: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits only enum and boolean dimensions for interaction creation compatibility", () => {
|
||||
const client = createClient();
|
||||
|
||||
trackInteractionCreated(client, {
|
||||
interactionKind: "request_confirmation",
|
||||
usedDeprecatedResolverPolicyAlias: true,
|
||||
});
|
||||
|
||||
expect(client.track).toHaveBeenCalledWith("interaction.created", {
|
||||
interaction_kind: "request_confirmation",
|
||||
used_deprecated_resolver_policy_alias: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import incident from "./fixtures/watchdog-confirmation-incident-8bef17ef.json";
|
||||
|
||||
describe("historical watchdog confirmation incident fixture", () => {
|
||||
it("reproduces the material stop fingerprint", () => {
|
||||
const materialPayload = JSON.stringify({
|
||||
version: incident.stopSnapshot.version,
|
||||
companyId: incident.companyId,
|
||||
watchedIssueId: incident.watchdogRun.watchedIssueId,
|
||||
materialLeaves: incident.stopSnapshot.materialLeaves,
|
||||
waitsByIssueId: incident.stopSnapshot.waitsByIssueId,
|
||||
});
|
||||
const fingerprint = `task_watchdog_stop:${createHash("sha256").update(materialPayload).digest("hex")}`;
|
||||
|
||||
expect(fingerprint).toBe(incident.watchdogRun.stopFingerprint);
|
||||
});
|
||||
|
||||
it("preserves why the watchdog could not resolve the board-only card", () => {
|
||||
expect(incident.interactionAtStop).toMatchObject({
|
||||
id: "8bef17ef-6df4-4e52-8333-f3a789580be0",
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
sourceRunId: incident.creatorRun.id,
|
||||
});
|
||||
expect(incident.watchdogAuthorizationAtStop.deniedOperations).toContain(
|
||||
"resolve_board_only_or_security_sensitive_approvals",
|
||||
);
|
||||
});
|
||||
|
||||
it("records manual acceptance without changing audit attribution or retaining a current wait", () => {
|
||||
expect(incident.manualAcceptance).toMatchObject({
|
||||
status: "accepted",
|
||||
resolverPolicy: "board_only",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByRunId: null,
|
||||
});
|
||||
expect(incident.manualAcceptance.resolvedByUserId).toBeTruthy();
|
||||
expect(incident.descendantPathAtVerification.at(-1)).toMatchObject({
|
||||
identifier: "PAP-17237",
|
||||
status: "done",
|
||||
});
|
||||
expect(incident.verification).toEqual({
|
||||
historicalInteractionStillPending: false,
|
||||
historicalInteractionIsCurrentWait: false,
|
||||
auditAttributionChanged: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -21,8 +21,8 @@ export function forbidden(message = "Forbidden", details?: unknown) {
|
|||
return new HttpError(403, message, details);
|
||||
}
|
||||
|
||||
export function notFound(message = "Not found") {
|
||||
return new HttpError(404, message);
|
||||
export function notFound(message = "Not found", details?: unknown) {
|
||||
return new HttpError(404, message, details);
|
||||
}
|
||||
|
||||
export function conflict(message: string, details?: unknown) {
|
||||
|
|
|
|||
|
|
@ -90,10 +90,12 @@ import {
|
|||
type IssueWakeDiagnosticsResponse,
|
||||
type IssueRelationIssueSummary,
|
||||
type IssueReviewPolicy,
|
||||
type IssueThreadInteractionCanonicalResolverPolicy,
|
||||
type IssueCommentPresentation,
|
||||
type IssueWatchdogDiscoveryKind,
|
||||
type ProjectWorkspace,
|
||||
type SourceTrustMetadata,
|
||||
type SuggestTasksInteraction,
|
||||
type SuccessfulRunHandoffState,
|
||||
type WorkspaceRuntimeService,
|
||||
issueWriteDenialCodeForResponsibleUserDenial,
|
||||
|
|
@ -228,7 +230,15 @@ import { deliverAgentUnblockNotification } from "../services/routable-blocked.js
|
|||
import {
|
||||
assertIssueReviewVerdictActorAllowed,
|
||||
isIssueReviewVerdictInteraction,
|
||||
resolveIssueReviewRequester,
|
||||
} from "../services/issue-review-policy.js";
|
||||
import {
|
||||
evaluateIssueThreadInteractionResolverAudience,
|
||||
issueThreadInteractionAttentionAgentAllowed,
|
||||
type IssueThreadInteractionResolverAudienceDecision,
|
||||
type IssueThreadInteractionResolverRestriction,
|
||||
} from "../services/issue-thread-interaction-resolution.js";
|
||||
import { resolveSelectedSuggestedTasks } from "../services/issue-thread-interactions.js";
|
||||
import {
|
||||
crossIssueInfluenceLimitError,
|
||||
crossIssueInfluenceRunContextError,
|
||||
|
|
@ -3943,6 +3953,7 @@ export function issueRoutes(
|
|||
status: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
reviewPolicy?: IssueReviewPolicy | null;
|
||||
/** Used only to name the task in denial copy (plan §6). */
|
||||
identifier?: string | null;
|
||||
},
|
||||
|
|
@ -4107,30 +4118,124 @@ export function issueRoutes(
|
|||
return false;
|
||||
}
|
||||
|
||||
async function rejectTaskWatchdogInteractionMutation(
|
||||
function denyIssueThreadInteractionResolution(
|
||||
res: Response,
|
||||
input: {
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
res.status(input.status).json({
|
||||
error: input.message,
|
||||
code: input.code,
|
||||
details: { code: input.code, ...(input.details ?? {}) },
|
||||
});
|
||||
return false as const;
|
||||
}
|
||||
|
||||
async function assertAgentInteractionRunAttribution(
|
||||
req: Request,
|
||||
res: Response,
|
||||
issue: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
parentId?: string | null;
|
||||
},
|
||||
) {
|
||||
if (req.actor.type !== "agent") return false;
|
||||
const scope = await resolveTaskWatchdogMutationScope(db, req.actor);
|
||||
if (scope.kind === "none") return false;
|
||||
const result = await taskWatchdogScopeAllowsIssueMutation(db, scope, issue);
|
||||
if (result.kind === "invalid") {
|
||||
res.status(403).json({
|
||||
error: result.detail,
|
||||
details: {
|
||||
issueId: issue.id,
|
||||
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
|
||||
},
|
||||
if (req.actor.type !== "agent") return null;
|
||||
const runId = req.actor.runId?.trim();
|
||||
if (!req.actor.agentId || !runId) {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 422,
|
||||
code: "interaction_run_attribution_required",
|
||||
message: "A valid authenticated agent run is required to resolve this issue-thread interaction",
|
||||
});
|
||||
}
|
||||
|
||||
const run = await db
|
||||
.select({
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(
|
||||
eq(heartbeatRuns.id, runId),
|
||||
eq(heartbeatRuns.companyId, issue.companyId),
|
||||
eq(heartbeatRuns.agentId, req.actor.agentId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const actorResponsibleUserId = req.actor.onBehalfOfUserId?.trim() || null;
|
||||
if (
|
||||
!run
|
||||
|| run.companyId !== issue.companyId
|
||||
|| run.agentId !== req.actor.agentId
|
||||
|| (
|
||||
actorResponsibleUserId !== null
|
||||
&& run.responsibleUserId !== undefined
|
||||
&& run.responsibleUserId !== actorResponsibleUserId
|
||||
)
|
||||
) {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 422,
|
||||
code: "interaction_run_attribution_required",
|
||||
message: "The authenticated agent run is not valid for this issue-thread interaction",
|
||||
});
|
||||
}
|
||||
return runId;
|
||||
}
|
||||
|
||||
async function assertIssueThreadInteractionContainmentAllowed(
|
||||
req: Request,
|
||||
res: Response,
|
||||
issue: Parameters<typeof assertAgentIssueMutationAllowed>[2],
|
||||
) {
|
||||
if (req.actor.type !== "agent") return true;
|
||||
if (await actorIsLowTrustReview(req, issue.companyId, issue)) {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_scope_denied",
|
||||
message: "This issue-thread interaction is outside the actor's trusted control-plane scope",
|
||||
});
|
||||
}
|
||||
|
||||
const watchdogScope = await resolveTaskWatchdogMutationScope(db, req.actor);
|
||||
if (watchdogScope.kind === "invalid") {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_scope_denied",
|
||||
message: watchdogScope.detail,
|
||||
});
|
||||
}
|
||||
if (watchdogScope.kind !== "none") {
|
||||
const scopeResult = await taskWatchdogScopeAllowsIssueMutation(db, watchdogScope, issue);
|
||||
if (scopeResult.kind === "invalid") {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_scope_denied",
|
||||
message: scopeResult.detail,
|
||||
});
|
||||
}
|
||||
const revalidated = await taskWatchdogsSvc.revalidateMutationScope(watchdogScope);
|
||||
if (!revalidated.allowed) {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_scope_denied",
|
||||
message: "This issue-thread interaction is outside the current watchdog scope",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
res.status(403).json({ error: "Task-watchdog runs cannot mutate issue-thread interactions" });
|
||||
|
||||
const boundaryDecision = await decideIssueAccess(req, issue, "issue:mutate");
|
||||
if (!boundaryDecision.allowed) {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_scope_denied",
|
||||
message: "This issue-thread interaction is outside the actor's authorized issue scope",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -4144,82 +4249,170 @@ export function issueRoutes(
|
|||
createdByUserId?: string | null;
|
||||
sourceRunId?: string | null;
|
||||
effectiveResolverPolicy: string;
|
||||
resolverPolicyProvenance?: string | null;
|
||||
addresseeAgentId?: string | null;
|
||||
kind: string;
|
||||
status: string;
|
||||
payload?: unknown;
|
||||
},
|
||||
runId: string | null,
|
||||
) {
|
||||
const isReviewConfirmationVerdict = await isPendingReviewConfirmationVerdict(issue, interaction);
|
||||
if (req.actor.type !== "agent") {
|
||||
assertBoard(req);
|
||||
if (isReviewConfirmationVerdict) {
|
||||
await assertPendingReviewInteractionVerdictAllowed(req, issue, interaction);
|
||||
return "review_verdict" as const;
|
||||
}
|
||||
return "standard" as const;
|
||||
const reviewRestriction = await resolvePendingReviewInteractionRestriction(issue, interaction);
|
||||
const resolverPolicyRestriction = reviewRestriction?.restriction ?? null;
|
||||
if (reviewRestriction?.binding === "legacy") {
|
||||
await assertPendingReviewInteractionVerdictAllowed(req, issue, interaction);
|
||||
}
|
||||
const actorAgentId = req.actor.agentId;
|
||||
const runId = requireAgentRunId(req, res);
|
||||
if (!actorAgentId || !runId) return false;
|
||||
const watchdogScope = await resolveTaskWatchdogMutationScope(db, req.actor);
|
||||
if (watchdogScope.kind !== "none") {
|
||||
res.status(403).json({ error: "Task-watchdog runs cannot resolve issue-thread interactions" });
|
||||
return false;
|
||||
}
|
||||
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return false;
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return false;
|
||||
const payload = interaction.payload && typeof interaction.payload === "object"
|
||||
? interaction.payload as { toolAction?: unknown }
|
||||
: null;
|
||||
if (interaction.kind === "request_confirmation" && payload?.toolAction !== undefined) {
|
||||
res.status(403).json({ error: "Tool-action confirmations are always board-only" });
|
||||
return false;
|
||||
const actor = getActorInfo(req);
|
||||
const decision: IssueThreadInteractionResolverAudienceDecision =
|
||||
evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: actor.actorType === "agent"
|
||||
? { type: "agent", agentId: actor.agentId, runId: runId || actor.runId }
|
||||
: { type: "user", userId: actor.actorId },
|
||||
interaction,
|
||||
additionalRestriction: resolverPolicyRestriction,
|
||||
governedAction: interaction.kind === "request_confirmation" && payload?.toolAction !== undefined,
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: decision.status,
|
||||
code: decision.code,
|
||||
message: decision.message,
|
||||
details: {
|
||||
effectiveResolverPolicy: decision.effectiveResolverPolicy,
|
||||
...(decision.details ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (isReviewConfirmationVerdict) {
|
||||
if (!assertAgentInteractionActorAllowed(res, interaction, actorAgentId, runId)) return false;
|
||||
await assertPendingReviewInteractionVerdictAllowed(req, issue, interaction);
|
||||
return "review_verdict" as const;
|
||||
}
|
||||
if (interaction.effectiveResolverPolicy !== "board_or_agents") {
|
||||
res.status(403).json({ error: "This issue-thread interaction is board-only" });
|
||||
return false;
|
||||
}
|
||||
return assertAgentInteractionActorAllowed(res, interaction, actorAgentId, runId)
|
||||
? "standard" as const
|
||||
: false;
|
||||
|
||||
// Resolving an interaction on another run's issue is a cross-issue mutation
|
||||
// like a comment or a PATCH, so it consumes the same per-run budget (§9.3,
|
||||
// §9.8.1). This runs last: company/resource access, run attribution,
|
||||
// containment, and the audience decision have all already passed, and the
|
||||
// terminal interaction mutation plus every child-task, continuation,
|
||||
// activity, tool, and wake side effect is still downstream. Same-issue
|
||||
// resolutions short-circuit inside the counter transaction and are not
|
||||
// charged, matching comment/update semantics.
|
||||
if (!(await assertCrossIssueInfluenceWithinRunCap(req, res, issue, "interaction_resolution"))) return false;
|
||||
return { decision, resolverPolicyRestriction } as const;
|
||||
}
|
||||
|
||||
function assertAgentInteractionActorAllowed(
|
||||
async function getIssueThreadInteractionResolutionAuthorization(
|
||||
req: Request,
|
||||
res: Response,
|
||||
interaction: {
|
||||
addresseeAgentId?: string | null;
|
||||
createdByAgentId?: string | null;
|
||||
sourceRunId?: string | null;
|
||||
},
|
||||
actorAgentId: string,
|
||||
runId: string,
|
||||
issue: Parameters<typeof assertAgentIssueMutationAllowed>[2],
|
||||
interactionId: string,
|
||||
) {
|
||||
if (interaction.addresseeAgentId && interaction.addresseeAgentId !== actorAgentId) {
|
||||
res.status(403).json({ error: "Only the addressed agent or a board user may resolve this issue-thread interaction" });
|
||||
return false;
|
||||
}
|
||||
if (interaction.createdByAgentId === actorAgentId) {
|
||||
res.status(403).json({ error: "Agents cannot resolve interactions they created" });
|
||||
return false;
|
||||
}
|
||||
if (interaction.sourceRunId === runId) {
|
||||
res.status(403).json({ error: "Agents cannot resolve interactions created by the same run" });
|
||||
return false;
|
||||
// Actor-only gates deliberately precede the interaction lookup. An actor
|
||||
// outside the issue's trusted/watchdog scope must not learn whether an
|
||||
// interaction id exists on that issue.
|
||||
const runId = await assertAgentInteractionRunAttribution(req, res, issue);
|
||||
if (runId === false) return false;
|
||||
if (!(await assertIssueThreadInteractionContainmentAllowed(req, res, issue))) return false;
|
||||
if (req.actor.type !== "agent") assertBoard(req);
|
||||
|
||||
const interactionSvc = issueThreadInteractionService(db);
|
||||
const current = await interactionSvc.getForIssue(issue, interactionId);
|
||||
const resolutionAuthorization = await assertIssueThreadInteractionResolutionAllowed(
|
||||
req,
|
||||
res,
|
||||
issue,
|
||||
current,
|
||||
runId,
|
||||
);
|
||||
if (!resolutionAuthorization) return false;
|
||||
return { interactionSvc, current, resolutionAuthorization } as const;
|
||||
}
|
||||
|
||||
async function assertSuggestedTaskEffectsAllowed(
|
||||
req: Request,
|
||||
res: Response,
|
||||
issue: Parameters<typeof assertAgentIssueMutationAllowed>[2] & {
|
||||
projectId: string | null;
|
||||
},
|
||||
interaction: SuggestTasksInteraction,
|
||||
selectedClientKeys: string[] | undefined,
|
||||
) {
|
||||
if (req.actor.type !== "agent") return true;
|
||||
const { selectedTasks } = resolveSelectedSuggestedTasks({ interaction, selectedClientKeys });
|
||||
for (const task of selectedTasks) {
|
||||
const explicitParentIssueId = task.parentId ?? interaction.payload.defaultParentId ?? issue.id;
|
||||
const parent = explicitParentIssueId === issue.id
|
||||
? issue
|
||||
: await svc.getById(explicitParentIssueId);
|
||||
if (!parent || parent.companyId !== issue.companyId) {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_governed_action_denied",
|
||||
message: "Suggested-task creation is outside the resolver's authorized issue scope",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const watchdogScope = await resolveTaskWatchdogMutationScope(db, req.actor);
|
||||
if (watchdogScope.kind === "invalid") {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_governed_action_denied",
|
||||
message: "Suggested-task creation is outside the current watchdog scope",
|
||||
});
|
||||
}
|
||||
if (watchdogScope.kind !== "none") {
|
||||
const scopeResult = await taskWatchdogScopeAllowsIssueMutation(
|
||||
db,
|
||||
watchdogScope,
|
||||
parent,
|
||||
{ allowWatchdogIssue: false },
|
||||
);
|
||||
if (scopeResult.kind === "invalid") {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_governed_action_denied",
|
||||
message: "Suggested-task creation is outside the current watchdog scope",
|
||||
});
|
||||
}
|
||||
const revalidated = await taskWatchdogsSvc.revalidateMutationScope(watchdogScope);
|
||||
if (!revalidated.allowed) {
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_governed_action_denied",
|
||||
message: "Suggested-task creation is outside the current watchdog scope",
|
||||
});
|
||||
}
|
||||
}
|
||||
await assertTaskBridgeCreateAllowed(req, issue.companyId, {
|
||||
projectId: task.projectId ?? issue.projectId,
|
||||
parentIssueId: parent.id,
|
||||
assigneeAgentId: task.assigneeAgentId ?? null,
|
||||
assigneeUserId: task.assigneeUserId ?? null,
|
||||
});
|
||||
if (task.assigneeAgentId || task.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, issue.companyId, {
|
||||
projectId: task.projectId ?? issue.projectId,
|
||||
parentIssueId: parent.id,
|
||||
assigneeAgentId: task.assigneeAgentId ?? null,
|
||||
assigneeUserId: task.assigneeUserId ?? null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError) || error.status !== 403) throw error;
|
||||
return denyIssueThreadInteractionResolution(res, {
|
||||
status: 403,
|
||||
code: "interaction_governed_action_denied",
|
||||
message: "Suggested-task creation requires independent authorization for every selected task",
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function isPendingReviewConfirmationVerdict(
|
||||
async function resolvePendingReviewInteractionRestriction(
|
||||
issue: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
reviewPolicy?: IssueReviewPolicy | null;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
},
|
||||
|
|
@ -4230,7 +4423,10 @@ export function issueRoutes(
|
|||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
},
|
||||
) {
|
||||
): Promise<{
|
||||
restriction: IssueThreadInteractionCanonicalResolverPolicy | IssueThreadInteractionResolverRestriction;
|
||||
binding: "explicit" | "legacy";
|
||||
} | null> {
|
||||
if (
|
||||
issue.status !== "in_review"
|
||||
|| interaction.status !== "pending"
|
||||
|
|
@ -4238,8 +4434,24 @@ export function issueRoutes(
|
|||
interaction.kind !== "request_confirmation"
|
||||
&& interaction.kind !== "request_checkbox_confirmation"
|
||||
)
|
||||
) return false;
|
||||
return isIssueReviewVerdictInteraction(db, { issue, interaction });
|
||||
) return null;
|
||||
if (!(await isIssueReviewVerdictInteraction(db, { issue, interaction }))) return null;
|
||||
const requester = await resolveIssueReviewRequester(db, issue);
|
||||
const binding = requester?.reviewInteractionId === interaction.id ? "explicit" : "legacy";
|
||||
if (issue.reviewPolicy == null || issue.reviewPolicy === "anyone") {
|
||||
return { restriction: "anyone", binding };
|
||||
}
|
||||
if (issue.reviewPolicy === "human_only") {
|
||||
return { restriction: { policy: "human_only", source: "issue_review" }, binding };
|
||||
}
|
||||
return {
|
||||
restriction: {
|
||||
policy: "not_creator",
|
||||
source: "issue_review",
|
||||
excludedActor: requester ? { type: requester.type, id: requester.id } : null,
|
||||
},
|
||||
binding,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertPendingReviewInteractionVerdictAllowed(
|
||||
|
|
@ -4252,7 +4464,13 @@ export function issueRoutes(
|
|||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
},
|
||||
interaction: { kind: string; status: string },
|
||||
interaction: {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
},
|
||||
) {
|
||||
if (
|
||||
issue.status !== "in_review"
|
||||
|
|
@ -4264,6 +4482,7 @@ export function issueRoutes(
|
|||
|| issue.reviewPolicy == null
|
||||
|| issue.reviewPolicy === "anyone"
|
||||
) return;
|
||||
if (!(await isIssueReviewVerdictInteraction(db, { issue, interaction }))) return;
|
||||
const actor = getActorInfo(req);
|
||||
await assertIssueReviewVerdictActorAllowed(db, {
|
||||
issue,
|
||||
|
|
@ -4282,19 +4501,8 @@ export function issueRoutes(
|
|||
return true;
|
||||
}
|
||||
const actorAgentId = req.actor.agentId;
|
||||
if (!actorAgentId || !requireAgentRunId(req, res)) return false;
|
||||
|
||||
const watchdogScope = await resolveTaskWatchdogMutationScope(db, req.actor);
|
||||
if (watchdogScope.kind !== "none") {
|
||||
res.status(403).json({ error: "Task-watchdog runs cannot withdraw issue-thread interactions" });
|
||||
return false;
|
||||
}
|
||||
const boundaryDecision = await decideIssueAccess(req, issue, "issue:mutate");
|
||||
if (!boundaryDecision.allowed) {
|
||||
res.status(403).json({ error: "Issue is outside this actor's authorization boundary" });
|
||||
return false;
|
||||
}
|
||||
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return false;
|
||||
if (!actorAgentId || await assertAgentInteractionRunAttribution(req, res, issue) === false) return false;
|
||||
if (!(await assertIssueThreadInteractionContainmentAllowed(req, res, issue))) return false;
|
||||
|
||||
const isCreator = interaction.createdByAgentId === actorAgentId;
|
||||
const isAssignee = issue.assigneeAgentId === actorAgentId;
|
||||
|
|
@ -10624,10 +10832,23 @@ export function issueRoutes(
|
|||
addresseeAgentId: interaction.addresseeAgentId ?? null,
|
||||
requestedResolverPolicy: interaction.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
|
||||
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
|
||||
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
|
||||
},
|
||||
});
|
||||
|
||||
if (interaction.addresseeAgentId) {
|
||||
if (
|
||||
interaction.addresseeAgentId
|
||||
&& issueThreadInteractionAttentionAgentAllowed({
|
||||
agentId: interaction.addresseeAgentId,
|
||||
interaction,
|
||||
governedAction: interaction.kind === "request_confirmation"
|
||||
&& typeof interaction.payload === "object"
|
||||
&& interaction.payload !== null
|
||||
&& "toolAction" in interaction.payload
|
||||
&& interaction.payload.toolAction !== undefined,
|
||||
})
|
||||
) {
|
||||
void heartbeat.wakeup(interaction.addresseeAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
|
|
@ -10672,20 +10893,32 @@ export function issueRoutes(
|
|||
const interactionId = req.params.interactionId as string;
|
||||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
|
||||
const interactionSvc = issueThreadInteractionService(db);
|
||||
const current = await interactionSvc.getForIssue(issue, interactionId);
|
||||
const resolutionAuthorization = await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current);
|
||||
if (!resolutionAuthorization) return;
|
||||
const authorizedResolution = await getIssueThreadInteractionResolutionAuthorization(
|
||||
req,
|
||||
res,
|
||||
issue,
|
||||
interactionId,
|
||||
);
|
||||
if (!authorizedResolution) return;
|
||||
const { interactionSvc, current, resolutionAuthorization } = authorizedResolution;
|
||||
const suggestedTaskEffectsAuthorized = current.kind === "suggest_tasks"
|
||||
? await assertSuggestedTaskEffectsAllowed(
|
||||
req,
|
||||
res,
|
||||
issue,
|
||||
current,
|
||||
req.body.selectedClientKeys,
|
||||
)
|
||||
: true;
|
||||
if (!suggestedTaskEffectsAuthorized) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const { interaction, createdIssues, continuationIssue } = await interactionSvc.acceptInteraction(issue, interactionId, req.body, {
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
...(resolutionAuthorization === "review_verdict"
|
||||
? { reviewVerdictAuthorized: true }
|
||||
: {}),
|
||||
resolverPolicyRestriction: resolutionAuthorization.resolverPolicyRestriction,
|
||||
suggestedTaskEffectsAuthorized,
|
||||
});
|
||||
const toolAction = interaction.payload && typeof interaction.payload === "object"
|
||||
? (interaction.payload as { toolAction?: { actionRequestId?: unknown } }).toolAction
|
||||
|
|
@ -10747,6 +10980,9 @@ export function issueRoutes(
|
|||
resolutionActorKind: actor.actorType,
|
||||
requestedResolverPolicy: interaction.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
|
||||
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
|
||||
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
|
||||
resolverAuthorizationReason: resolutionAuthorization.decision.reason,
|
||||
createdTaskCount:
|
||||
interaction.kind === "suggest_tasks"
|
||||
? (interaction.result?.createdTasks?.length ?? 0)
|
||||
|
|
@ -10828,20 +11064,21 @@ export function issueRoutes(
|
|||
const interactionId = req.params.interactionId as string;
|
||||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
|
||||
const interactionSvc = issueThreadInteractionService(db);
|
||||
const current = await interactionSvc.getForIssue(issue, interactionId);
|
||||
const resolutionAuthorization = await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current);
|
||||
if (!resolutionAuthorization) return;
|
||||
const authorizedResolution = await getIssueThreadInteractionResolutionAuthorization(
|
||||
req,
|
||||
res,
|
||||
issue,
|
||||
interactionId,
|
||||
);
|
||||
if (!authorizedResolution) return;
|
||||
const { interactionSvc, resolutionAuthorization } = authorizedResolution;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const interaction = await interactionSvc.rejectInteraction(issue, interactionId, req.body, {
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
...(resolutionAuthorization === "review_verdict"
|
||||
? { reviewVerdictAuthorized: true }
|
||||
: {}),
|
||||
resolverPolicyRestriction: resolutionAuthorization.resolverPolicyRestriction,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
|
|
@ -10863,6 +11100,9 @@ export function issueRoutes(
|
|||
resolutionActorKind: actor.actorType,
|
||||
requestedResolverPolicy: interaction.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
|
||||
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
|
||||
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
|
||||
resolverAuthorizationReason: resolutionAuthorization.decision.reason,
|
||||
rejectionReason:
|
||||
interaction.kind === "suggest_tasks"
|
||||
? (interaction.result?.rejectionReason ?? null)
|
||||
|
|
@ -10893,16 +11133,21 @@ export function issueRoutes(
|
|||
const interactionId = req.params.interactionId as string;
|
||||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
|
||||
const interactionSvc = issueThreadInteractionService(db);
|
||||
const current = await interactionSvc.getForIssue(issue, interactionId);
|
||||
if (!(await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current))) return;
|
||||
const authorizedResolution = await getIssueThreadInteractionResolutionAuthorization(
|
||||
req,
|
||||
res,
|
||||
issue,
|
||||
interactionId,
|
||||
);
|
||||
if (!authorizedResolution) return;
|
||||
const { interactionSvc, resolutionAuthorization } = authorizedResolution;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const interaction = await interactionSvc.answerQuestions(issue, interactionId, req.body, {
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
resolverPolicyRestriction: resolutionAuthorization.resolverPolicyRestriction,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
|
|
@ -10922,6 +11167,9 @@ export function issueRoutes(
|
|||
resolutionActorKind: actor.actorType,
|
||||
requestedResolverPolicy: interaction.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
|
||||
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
|
||||
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
|
||||
resolverAuthorizationReason: resolutionAuthorization.decision.reason,
|
||||
answeredQuestionCount:
|
||||
interaction.kind === "ask_user_questions"
|
||||
? (interaction.result?.answers?.length ?? 0)
|
||||
|
|
@ -10950,10 +11198,14 @@ export function issueRoutes(
|
|||
const interactionId = req.params.interactionId as string;
|
||||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
|
||||
const interactionSvc = issueThreadInteractionService(db);
|
||||
const current = await interactionSvc.getForIssue(issue, interactionId);
|
||||
if (!(await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current))) return;
|
||||
const authorizedResolution = await getIssueThreadInteractionResolutionAuthorization(
|
||||
req,
|
||||
res,
|
||||
issue,
|
||||
interactionId,
|
||||
);
|
||||
if (!authorizedResolution) return;
|
||||
const { interactionSvc, resolutionAuthorization } = authorizedResolution;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const { interaction, newlyResolvedItemIds } = await interactionSvc.submitItemVerdicts(
|
||||
|
|
@ -10964,6 +11216,7 @@ export function issueRoutes(
|
|||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
resolverPolicyRestriction: resolutionAuthorization.resolverPolicyRestriction,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -10986,6 +11239,9 @@ export function issueRoutes(
|
|||
resolutionActorKind: actor.actorType,
|
||||
requestedResolverPolicy: interaction.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
|
||||
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
|
||||
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
|
||||
resolverAuthorizationReason: resolutionAuthorization.decision.reason,
|
||||
submittedVerdictCount: Array.isArray(req.body?.verdicts) ? req.body.verdicts.length : 0,
|
||||
newlyResolvedItemCount: newlyResolvedItemIds.length,
|
||||
newlyResolvedItemIds,
|
||||
|
|
@ -11024,7 +11280,6 @@ export function issueRoutes(
|
|||
const interactionId = req.params.interactionId as string;
|
||||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return;
|
||||
|
||||
const interactionSvc = issueThreadInteractionService(db);
|
||||
const current = await interactionSvc.getForIssue(issue, interactionId);
|
||||
|
|
@ -11034,6 +11289,7 @@ export function issueRoutes(
|
|||
const actor = getActorInfo(req);
|
||||
const interaction = await interactionSvc.withdrawInteraction(issue, interactionId, req.body, {
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
await logActivity(db, {
|
||||
|
|
|
|||
|
|
@ -4607,6 +4607,8 @@ registry.registerPath({
|
|||
path: "/api/issues/{id}/interactions",
|
||||
tags: ["issues"],
|
||||
summary: "Create an issue thread interaction",
|
||||
description:
|
||||
"Resolver policy defaults to canonical `anyone` for every interaction kind. `not_creator` and `human_only` are opt-in restrictions; deprecated `board_or_agents` and `board_only` inputs are accepted as compatibility aliases.",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(createIssueThreadInteractionSchema),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { interactionResolverAudience } from "./attention.js";
|
||||
|
||||
const AGENT_NAMES: Record<string, string> = {
|
||||
"agent-watchdog": "Watchdog",
|
||||
"agent-codex": "CodexCoder",
|
||||
};
|
||||
|
||||
const agentName = (agentId: string) => AGENT_NAMES[agentId] ?? null;
|
||||
|
||||
function row(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
addresseeAgentId: null,
|
||||
createdByAgentId: "agent-watchdog",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
...overrides,
|
||||
} as Parameters<typeof interactionResolverAudience>[0];
|
||||
}
|
||||
|
||||
describe("attention feed resolver audience", () => {
|
||||
it("carries the open default with the creator named", () => {
|
||||
expect(interactionResolverAudience(row(), agentName)).toEqual({
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
addresseeAgentId: null,
|
||||
addresseeName: null,
|
||||
createdByAgentId: "agent-watchdog",
|
||||
createdByAgentName: "Watchdog",
|
||||
});
|
||||
});
|
||||
|
||||
it("names the addressed agent", () => {
|
||||
expect(interactionResolverAudience(row({ addresseeAgentId: "agent-codex" }), agentName))
|
||||
.toMatchObject({ addresseeAgentId: "agent-codex", addresseeName: "CodexCoder" });
|
||||
});
|
||||
|
||||
it("reports the company cap that narrowed the requested audience", () => {
|
||||
expect(interactionResolverAudience(
|
||||
row({
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
effectiveResolverPolicySource: "company_cap",
|
||||
}),
|
||||
agentName,
|
||||
)).toMatchObject({
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
effectiveResolverPolicySource: "company_cap",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a pre-migration board_or_agents row restricted, matching the evaluator", () => {
|
||||
// The queue must not read `Anyone` while the resolution routes still treat
|
||||
// the card as creator-excluded.
|
||||
expect(interactionResolverAudience(
|
||||
row({
|
||||
requestedResolverPolicy: "board_or_agents",
|
||||
effectiveResolverPolicy: "board_or_agents",
|
||||
resolverPolicyProvenance: "legacy_inherited_restriction",
|
||||
}),
|
||||
agentName,
|
||||
)).toMatchObject({
|
||||
requestedResolverPolicy: "not_creator",
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
resolverPolicyProvenance: "legacy_inherited_restriction",
|
||||
});
|
||||
});
|
||||
|
||||
it("infers legacy provenance for a row written before the column existed", () => {
|
||||
expect(interactionResolverAudience(
|
||||
row({
|
||||
requestedResolverPolicy: "board_only",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
resolverPolicyProvenance: null,
|
||||
effectiveResolverPolicySource: null,
|
||||
}),
|
||||
agentName,
|
||||
)).toMatchObject({
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "legacy_inherited_restriction",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an unknown agent unnamed rather than inventing a responder", () => {
|
||||
expect(interactionResolverAudience(
|
||||
row({ addresseeAgentId: "agent-deleted", createdByAgentId: null }),
|
||||
agentName,
|
||||
)).toMatchObject({
|
||||
addresseeName: null,
|
||||
createdByAgentId: null,
|
||||
createdByAgentName: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -37,12 +37,15 @@ import type {
|
|||
AttentionItemDetail,
|
||||
AttentionProjectRef,
|
||||
AttentionQueueRef,
|
||||
AttentionResolverAudience,
|
||||
AttentionSeverity,
|
||||
AttentionSortMode,
|
||||
AttentionSourceKind,
|
||||
AttentionSubject,
|
||||
AttentionTriageAttribution,
|
||||
AttentionWorkspaceRef,
|
||||
IssueThreadInteractionEffectiveResolverPolicySource,
|
||||
IssueThreadInteractionResolverPolicyProvenance,
|
||||
IssueReviewPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { badRequest } from "../errors.js";
|
||||
|
|
@ -57,6 +60,7 @@ import { visibleIssueCondition } from "./issue-visibility.js";
|
|||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
|
||||
import { evaluateAgentInvokability, type AgentOrgRow } from "./agent-invokability.js";
|
||||
import { canonicalizeStoredResolverPolicy } from "./issue-thread-interaction-resolution.js";
|
||||
import { decisionQueueService } from "./decision-queues.js";
|
||||
import {
|
||||
decisionRetentionService,
|
||||
|
|
@ -411,6 +415,7 @@ function createItem(input: CreateAttentionItemInput): AttentionItem {
|
|||
snoozedUntil: null,
|
||||
detail: input.detail ?? null,
|
||||
trainingExampleId: null,
|
||||
resolverAudience: input.resolverAudience ?? null,
|
||||
rank: 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -714,6 +719,45 @@ function interactionVerbs(kind: string, payload: Record<string, unknown>) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolver audience carried by an `issue_thread_interaction` feed row
|
||||
* (PAP-17287). A collapsed queue row offers Accept/Reject long before anything
|
||||
* fetches the interaction itself, so the audience the server will enforce has
|
||||
* to ride along with the item — the queue must never ask for a decision without
|
||||
* saying whose decision it is.
|
||||
*
|
||||
* Facts only. The stored columns are canonicalized through the same helper the
|
||||
* resolution evaluator uses, so a pre-migration row cannot read as `Anyone`
|
||||
* here while the API still treats it as `not_creator`.
|
||||
*/
|
||||
export function interactionResolverAudience(
|
||||
row: {
|
||||
addresseeAgentId: string | null;
|
||||
createdByAgentId: string | null;
|
||||
requestedResolverPolicy: string;
|
||||
effectiveResolverPolicy: string;
|
||||
resolverPolicyProvenance: string | null;
|
||||
effectiveResolverPolicySource: string | null;
|
||||
},
|
||||
agentName: (agentId: string) => string | null,
|
||||
): AttentionResolverAudience {
|
||||
const provenance = (row.resolverPolicyProvenance
|
||||
?? (row.requestedResolverPolicy === "board_only" || row.requestedResolverPolicy === "board_or_agents"
|
||||
? "legacy_inherited_restriction"
|
||||
: "inherited")) as IssueThreadInteractionResolverPolicyProvenance;
|
||||
return {
|
||||
requestedResolverPolicy: canonicalizeStoredResolverPolicy(row.requestedResolverPolicy, provenance),
|
||||
effectiveResolverPolicy: canonicalizeStoredResolverPolicy(row.effectiveResolverPolicy, provenance),
|
||||
effectiveResolverPolicySource:
|
||||
(row.effectiveResolverPolicySource ?? "requested") as IssueThreadInteractionEffectiveResolverPolicySource,
|
||||
resolverPolicyProvenance: provenance,
|
||||
addresseeAgentId: row.addresseeAgentId,
|
||||
addresseeName: row.addresseeAgentId ? agentName(row.addresseeAgentId) : null,
|
||||
createdByAgentId: row.createdByAgentId,
|
||||
createdByAgentName: row.createdByAgentId ? agentName(row.createdByAgentId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function collapsePendingConfirmationsToNewest<T extends {
|
||||
id: string;
|
||||
issueId: string;
|
||||
|
|
@ -1129,6 +1173,10 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||
payload: issueThreadInteractions.payload,
|
||||
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
||||
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
||||
requestedResolverPolicy: issueThreadInteractions.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: issueThreadInteractions.effectiveResolverPolicy,
|
||||
resolverPolicyProvenance: issueThreadInteractions.resolverPolicyProvenance,
|
||||
effectiveResolverPolicySource: issueThreadInteractions.effectiveResolverPolicySource,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
updatedAt: issueThreadInteractions.updatedAt,
|
||||
})
|
||||
|
|
@ -1138,7 +1186,14 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||
inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]),
|
||||
))
|
||||
.orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id));
|
||||
const companyAgentRows: AgentOrgRow[] = interactionRows.some((row) => row.addresseeAgentId !== null)
|
||||
// Addressee invokability needs the org graph; the audience line also needs
|
||||
// the creator's name whenever the effective policy excludes it, so a
|
||||
// creator-excluding row pulls the roster in too (PAP-17287).
|
||||
const needsCompanyAgents = interactionRows.some((row) =>
|
||||
row.addresseeAgentId !== null
|
||||
|| canonicalizeStoredResolverPolicy(row.effectiveResolverPolicy, row.resolverPolicyProvenance) === "not_creator"
|
||||
);
|
||||
const companyAgentRows: AgentOrgRow[] = needsCompanyAgents
|
||||
? await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
|
|
@ -1206,6 +1261,10 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||
relatedIssue: issue ? issueSubject(prefix, issue) : null,
|
||||
...issueContext(issue),
|
||||
detail,
|
||||
resolverAudience: interactionResolverAudience(
|
||||
interaction,
|
||||
(agentId) => companyAgentMap.get(agentId)?.name ?? null,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ export const CROSS_ISSUE_INFLUENCE_ENFORCE_AT = new Date("2026-08-11T00:00:00.00
|
|||
const CROSS_ISSUE_INFLUENCE_ACTIVITY = "issue.cross_issue_influence_observed";
|
||||
const CROSS_ISSUE_INFLUENCE_REJECTED_ACTIVITY = "issue.cross_issue_influence_cap_rejected";
|
||||
|
||||
export type CrossIssueInfluenceKind = "comment" | "update";
|
||||
/**
|
||||
* Every kind shares one per-run counter. `interaction_resolution` covers the
|
||||
* issue-thread accept/reject/respond/verdict routes: an open `anyone` resolver
|
||||
* audience is not a licence to resolve, wake, and spawn suggested tasks across
|
||||
* the whole company from one run.
|
||||
*/
|
||||
export type CrossIssueInfluenceKind = "comment" | "update" | "interaction_resolution";
|
||||
|
||||
export type CrossIssueInfluenceDecision = {
|
||||
allowed: boolean;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface IssueReviewVerdictActor {
|
|||
id: string;
|
||||
}
|
||||
|
||||
interface IssueReviewRequester extends IssueReviewVerdictActor {
|
||||
export interface IssueReviewRequester extends IssueReviewVerdictActor {
|
||||
reviewInteractionId: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ interface ReviewPolicyIssue {
|
|||
createdByUserId?: string | null;
|
||||
}
|
||||
|
||||
async function findReviewRequester(
|
||||
export async function resolveIssueReviewRequester(
|
||||
db: Db,
|
||||
issue: ReviewPolicyIssue,
|
||||
): Promise<IssueReviewRequester | null> {
|
||||
|
|
@ -80,7 +80,7 @@ export async function isIssueReviewVerdictInteraction(
|
|||
};
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const requester = await findReviewRequester(db, input.issue);
|
||||
const requester = await resolveIssueReviewRequester(db, input.issue);
|
||||
if (!requester) return false;
|
||||
if (requester.reviewInteractionId && requester.reviewInteractionId !== input.interaction.id) return false;
|
||||
// Older review transitions did not persist the interaction binding. In that
|
||||
|
|
@ -121,7 +121,7 @@ export async function assertIssueReviewVerdictActorAllowed(
|
|||
);
|
||||
}
|
||||
|
||||
const requester = await findReviewRequester(db, input.issue);
|
||||
const requester = await resolveIssueReviewRequester(db, input.issue);
|
||||
if (!requester) {
|
||||
throw forbidden(
|
||||
"Review policy `not_creator` requires a different writer, but the review requester could not be determined.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateIssueThreadInteractionResolverAudience,
|
||||
issueThreadInteractionAttentionAgentAllowed,
|
||||
} from "./issue-thread-interaction-resolution.js";
|
||||
|
||||
const agent = { type: "agent", agentId: "agent-1", runId: "run-1" } as const;
|
||||
|
||||
function interaction(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
createdByAgentId: "agent-1",
|
||||
createdByUserId: null,
|
||||
sourceRunId: "run-1",
|
||||
addresseeAgentId: null,
|
||||
effectiveResolverPolicy: "anyone",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue-thread interaction resolver audience", () => {
|
||||
it.each([
|
||||
"suggest_tasks",
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
"request_checkbox_confirmation",
|
||||
"request_item_verdicts",
|
||||
])("allows the creator and creating run under anyone for %s", () => {
|
||||
expect(evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: agent,
|
||||
interaction: interaction(),
|
||||
})).toMatchObject({ allowed: true, effectiveResolverPolicy: "anyone", reason: "allow_anyone" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["creator agent", { createdByAgentId: "agent-1", sourceRunId: "run-other" }],
|
||||
["creating run", { createdByAgentId: "agent-other", sourceRunId: "run-1" }],
|
||||
])("excludes the %s only when not_creator is effective", (_name, creator) => {
|
||||
const decision = evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: agent,
|
||||
interaction: interaction({ ...creator, effectiveResolverPolicy: "not_creator" }),
|
||||
});
|
||||
expect(decision).toMatchObject({ allowed: false, code: "interaction_creator_excluded" });
|
||||
});
|
||||
|
||||
it("enforces an explicit addressee", () => {
|
||||
const decision = evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: agent,
|
||||
interaction: interaction({ addresseeAgentId: "agent-2" }),
|
||||
});
|
||||
expect(decision).toMatchObject({ allowed: false, code: "interaction_addressee_mismatch" });
|
||||
});
|
||||
|
||||
it("requires run attribution for agents", () => {
|
||||
const decision = evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: { type: "agent", agentId: "agent-1", runId: null },
|
||||
interaction: interaction(),
|
||||
});
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
status: 422,
|
||||
code: "interaction_run_attribution_required",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a server-derived restriction to narrow but never widen the audience", () => {
|
||||
expect(evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: agent,
|
||||
interaction: interaction({ createdByAgentId: "agent-other", sourceRunId: "run-other" }),
|
||||
additionalRestriction: "human_only",
|
||||
})).toMatchObject({ allowed: false, code: "interaction_human_only" });
|
||||
|
||||
expect(evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: agent,
|
||||
interaction: interaction({ effectiveResolverPolicy: "human_only" }),
|
||||
additionalRestriction: "anyone",
|
||||
})).toMatchObject({ allowed: false, code: "interaction_human_only" });
|
||||
});
|
||||
|
||||
it("intersects human-only and creator-separation restrictions", () => {
|
||||
expect(evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: { type: "user", userId: "user-1" },
|
||||
interaction: interaction({
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
}),
|
||||
additionalRestriction: "human_only",
|
||||
})).toMatchObject({ allowed: false, code: "interaction_creator_excluded" });
|
||||
|
||||
expect(evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: { type: "user", userId: "user-1" },
|
||||
interaction: interaction({
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
}),
|
||||
additionalRestriction: "not_creator",
|
||||
})).toMatchObject({ allowed: false, code: "interaction_creator_excluded" });
|
||||
});
|
||||
|
||||
it("excludes the review requester when a review card has null creator columns", () => {
|
||||
const restriction = {
|
||||
policy: "not_creator",
|
||||
excludedActor: { type: "user", id: "review-requester" },
|
||||
source: "issue_review",
|
||||
} as const;
|
||||
expect(evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: { type: "user", userId: "review-requester" },
|
||||
interaction: interaction({
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
sourceRunId: null,
|
||||
}),
|
||||
additionalRestriction: restriction,
|
||||
})).toMatchObject({
|
||||
allowed: false,
|
||||
code: "review_policy_denied",
|
||||
details: {
|
||||
policy: "not_creator",
|
||||
allowedActor: "writer_other_than_review_requester",
|
||||
},
|
||||
});
|
||||
expect(evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: { type: "user", userId: "peer-reviewer" },
|
||||
interaction: interaction({
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
sourceRunId: null,
|
||||
}),
|
||||
additionalRestriction: restriction,
|
||||
})).toMatchObject({ allowed: true, effectiveResolverPolicy: "not_creator" });
|
||||
});
|
||||
|
||||
it("keeps governed actions independently human-only", () => {
|
||||
const decision = evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: agent,
|
||||
interaction: interaction(),
|
||||
governedAction: true,
|
||||
});
|
||||
expect(decision).toMatchObject({ allowed: false, code: "interaction_governed_action_denied" });
|
||||
});
|
||||
|
||||
it("preserves legacy inherited board_or_agents creator separation", () => {
|
||||
const decision = evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: agent,
|
||||
interaction: interaction({
|
||||
effectiveResolverPolicy: "board_or_agents",
|
||||
resolverPolicyProvenance: "legacy_inherited_restriction",
|
||||
}),
|
||||
});
|
||||
expect(decision).toMatchObject({ allowed: false, code: "interaction_creator_excluded" });
|
||||
});
|
||||
|
||||
it("derives agent attention ownership from the same effective audience", () => {
|
||||
expect(issueThreadInteractionAttentionAgentAllowed({
|
||||
agentId: "agent-1",
|
||||
interaction: interaction(),
|
||||
})).toBe(true);
|
||||
expect(issueThreadInteractionAttentionAgentAllowed({
|
||||
agentId: "agent-1",
|
||||
interaction: interaction({ effectiveResolverPolicy: "not_creator" }),
|
||||
})).toBe(false);
|
||||
expect(issueThreadInteractionAttentionAgentAllowed({
|
||||
agentId: "agent-2",
|
||||
interaction: interaction({ effectiveResolverPolicy: "human_only" }),
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
import type {
|
||||
IssueThreadInteractionCanonicalResolverPolicy,
|
||||
IssueThreadInteractionResolverPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { normalizeIssueThreadInteractionResolverPolicy } from "@paperclipai/shared";
|
||||
import { HttpError } from "../errors.js";
|
||||
|
||||
export const ISSUE_THREAD_INTERACTION_RESOLUTION_DENIAL_CODES = [
|
||||
"interaction_not_found",
|
||||
"interaction_run_attribution_required",
|
||||
"interaction_scope_denied",
|
||||
"interaction_human_only",
|
||||
"interaction_creator_excluded",
|
||||
"interaction_addressee_mismatch",
|
||||
"interaction_stale_target",
|
||||
"interaction_superseded",
|
||||
"interaction_already_resolved",
|
||||
"interaction_issue_closed",
|
||||
"interaction_governed_action_denied",
|
||||
"review_policy_denied",
|
||||
] as const;
|
||||
|
||||
export type IssueThreadInteractionResolutionDenialCode =
|
||||
(typeof ISSUE_THREAD_INTERACTION_RESOLUTION_DENIAL_CODES)[number];
|
||||
|
||||
export type IssueThreadInteractionResolverActor =
|
||||
| { type: "user"; userId: string }
|
||||
| { type: "agent"; agentId: string | null | undefined; runId: string | null | undefined }
|
||||
| { type: "system"; systemId: string };
|
||||
|
||||
export type IssueThreadInteractionResolverRestriction = {
|
||||
policy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
/**
|
||||
* A server-resolved identity excluded by the binding flow in addition to the
|
||||
* interaction creator. `null` means the binding requires an identity but it
|
||||
* could not be resolved, so the evaluator must fail closed.
|
||||
*/
|
||||
excludedActor?: { type: "agent" | "user"; id: string } | null;
|
||||
source?: "issue_review";
|
||||
};
|
||||
|
||||
export type IssueThreadInteractionResolverAudienceInput = {
|
||||
actor: IssueThreadInteractionResolverActor;
|
||||
interaction: {
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
sourceRunId?: string | null;
|
||||
addresseeAgentId?: string | null;
|
||||
effectiveResolverPolicy: IssueThreadInteractionResolverPolicy | string;
|
||||
resolverPolicyProvenance?: string | null;
|
||||
};
|
||||
/**
|
||||
* A server-derived restriction owned by a binding flow such as issue review.
|
||||
* It may only narrow the interaction's persisted effective audience.
|
||||
*/
|
||||
additionalRestriction?:
|
||||
| IssueThreadInteractionCanonicalResolverPolicy
|
||||
| IssueThreadInteractionResolverRestriction
|
||||
| null;
|
||||
governedAction?: boolean;
|
||||
};
|
||||
|
||||
export type IssueThreadInteractionResolverAudienceDecision =
|
||||
| {
|
||||
allowed: true;
|
||||
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
reason: "allow_anyone" | "allow_addressee" | "allow_human" | "allow_system" | "allow_human_override";
|
||||
}
|
||||
| {
|
||||
allowed: false;
|
||||
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
status: 403 | 422;
|
||||
code: Extract<
|
||||
IssueThreadInteractionResolutionDenialCode,
|
||||
| "interaction_run_attribution_required"
|
||||
| "interaction_human_only"
|
||||
| "interaction_creator_excluded"
|
||||
| "interaction_addressee_mismatch"
|
||||
| "interaction_governed_action_denied"
|
||||
| "review_policy_denied"
|
||||
>;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Canonicalize one stored resolver-policy value.
|
||||
*
|
||||
* Before provenance existed, `board_or_agents` excluded both the creator agent
|
||||
* and the creating run, so a pre-migration row must stay `not_creator` rather
|
||||
* than widening to canonical `anyone`. Every reader of the stored columns —
|
||||
* hydration, the audience evaluator, and the attention feed — goes through here
|
||||
* so none of them can disagree about a legacy row.
|
||||
*/
|
||||
export function canonicalizeStoredResolverPolicy(
|
||||
policy: IssueThreadInteractionResolverPolicy | string,
|
||||
resolverPolicyProvenance: string | null | undefined,
|
||||
): IssueThreadInteractionCanonicalResolverPolicy {
|
||||
if (resolverPolicyProvenance === "legacy_inherited_restriction" && policy === "board_or_agents") {
|
||||
return "not_creator";
|
||||
}
|
||||
return normalizeIssueThreadInteractionResolverPolicy(policy as IssueThreadInteractionResolverPolicy);
|
||||
}
|
||||
|
||||
function canonicalStoredPolicy(input: IssueThreadInteractionResolverAudienceInput["interaction"]) {
|
||||
return canonicalizeStoredResolverPolicy(
|
||||
input.effectiveResolverPolicy,
|
||||
input.resolverPolicyProvenance,
|
||||
);
|
||||
}
|
||||
|
||||
function reviewRestrictionExcludesActor(
|
||||
restriction: IssueThreadInteractionResolverRestriction | null | undefined,
|
||||
actor: { type: "agent" | "user"; id: string },
|
||||
) {
|
||||
return restriction?.policy === "not_creator"
|
||||
&& restriction.source === "issue_review"
|
||||
&& (
|
||||
restriction.excludedActor === null
|
||||
|| (
|
||||
restriction.excludedActor?.type === actor.type
|
||||
&& restriction.excludedActor.id === actor.id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function reviewPolicyDeniedDecision(
|
||||
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy,
|
||||
requesterKnown: boolean,
|
||||
): Extract<IssueThreadInteractionResolverAudienceDecision, { allowed: false }> {
|
||||
return {
|
||||
allowed: false,
|
||||
effectiveResolverPolicy,
|
||||
status: 403,
|
||||
code: "review_policy_denied",
|
||||
message: requesterKnown
|
||||
? "Review policy `not_creator` requires someone other than the writer who moved the issue into `in_review` to approve or reject it."
|
||||
: "Review policy `not_creator` requires a different writer, but the review requester could not be determined.",
|
||||
details: {
|
||||
policy: "not_creator",
|
||||
allowedActor: "writer_other_than_review_requester",
|
||||
remediation: requesterKnown
|
||||
? "Have another writer with issue write access submit the verdict, or change reviewPolicy to `anyone`."
|
||||
: "Change reviewPolicy to `anyone`, or move the issue out of and back into `in_review` to record a requester before another writer submits the verdict.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The single pure audience evaluator for issue-thread interaction resolution.
|
||||
* Resource access, run validation, containment, and current-target checks must
|
||||
* happen before this evaluator. Mutation routes must re-run it at use time.
|
||||
*/
|
||||
export function evaluateIssueThreadInteractionResolverAudience(
|
||||
input: IssueThreadInteractionResolverAudienceInput,
|
||||
): IssueThreadInteractionResolverAudienceDecision {
|
||||
const persistedPolicy = canonicalStoredPolicy(input.interaction);
|
||||
const additionalRestriction = typeof input.additionalRestriction === "string"
|
||||
? { policy: input.additionalRestriction }
|
||||
: input.additionalRestriction;
|
||||
// human_only and not_creator are independent constraints, not a linear
|
||||
// severity scale: their intersection means "a human other than the
|
||||
// creator." Keep both predicates even though the persisted public policy
|
||||
// enum can only name one of them.
|
||||
const creatorExcluded =
|
||||
persistedPolicy === "not_creator"
|
||||
|| additionalRestriction?.policy === "not_creator";
|
||||
const humanOnly =
|
||||
Boolean(input.governedAction)
|
||||
|| persistedPolicy === "human_only"
|
||||
|| additionalRestriction?.policy === "human_only";
|
||||
const effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy = humanOnly
|
||||
? "human_only"
|
||||
: creatorExcluded
|
||||
? "not_creator"
|
||||
: "anyone";
|
||||
|
||||
if (input.actor.type === "system") {
|
||||
return { allowed: true, effectiveResolverPolicy, reason: "allow_system" };
|
||||
}
|
||||
|
||||
if (input.actor.type === "user") {
|
||||
if (
|
||||
creatorExcluded
|
||||
&& input.interaction.createdByUserId === input.actor.userId
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
effectiveResolverPolicy,
|
||||
status: 403,
|
||||
code: "interaction_creator_excluded",
|
||||
message: "This issue-thread interaction requires a resolver other than its creator",
|
||||
};
|
||||
}
|
||||
if (reviewRestrictionExcludesActor(additionalRestriction, {
|
||||
type: "user",
|
||||
id: input.actor.userId,
|
||||
})) {
|
||||
return reviewPolicyDeniedDecision(
|
||||
effectiveResolverPolicy,
|
||||
additionalRestriction?.excludedActor !== null,
|
||||
);
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
effectiveResolverPolicy,
|
||||
reason: input.interaction.addresseeAgentId ? "allow_human_override" : "allow_human",
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.actor.agentId || !input.actor.runId?.trim()) {
|
||||
return {
|
||||
allowed: false,
|
||||
effectiveResolverPolicy,
|
||||
status: 422,
|
||||
code: "interaction_run_attribution_required",
|
||||
message: "A valid authenticated agent run is required to resolve this issue-thread interaction",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.governedAction) {
|
||||
return {
|
||||
allowed: false,
|
||||
effectiveResolverPolicy,
|
||||
status: 403,
|
||||
code: "interaction_governed_action_denied",
|
||||
message: "This interaction is bound to a governed action that requires independent authorization",
|
||||
};
|
||||
}
|
||||
|
||||
if (effectiveResolverPolicy === "human_only") {
|
||||
return {
|
||||
allowed: false,
|
||||
effectiveResolverPolicy,
|
||||
status: 403,
|
||||
code: "interaction_human_only",
|
||||
message: "This issue-thread interaction is human-only",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
input.interaction.addresseeAgentId
|
||||
&& input.interaction.addresseeAgentId !== input.actor.agentId
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
effectiveResolverPolicy,
|
||||
status: 403,
|
||||
code: "interaction_addressee_mismatch",
|
||||
message: "Only the addressed agent or an authorized human may resolve this issue-thread interaction",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
creatorExcluded
|
||||
&& (
|
||||
input.interaction.createdByAgentId === input.actor.agentId
|
||||
|| (
|
||||
Boolean(input.interaction.sourceRunId)
|
||||
&& input.interaction.sourceRunId === input.actor.runId
|
||||
)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
effectiveResolverPolicy,
|
||||
status: 403,
|
||||
code: "interaction_creator_excluded",
|
||||
message: "This issue-thread interaction requires a resolver other than its creator or creating run",
|
||||
};
|
||||
}
|
||||
|
||||
if (reviewRestrictionExcludesActor(additionalRestriction, {
|
||||
type: "agent",
|
||||
id: input.actor.agentId,
|
||||
})) {
|
||||
return reviewPolicyDeniedDecision(
|
||||
effectiveResolverPolicy,
|
||||
additionalRestriction?.excludedActor !== null,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
effectiveResolverPolicy,
|
||||
reason: input.interaction.addresseeAgentId ? "allow_addressee" : "allow_anyone",
|
||||
};
|
||||
}
|
||||
|
||||
export function issueThreadInteractionResolutionError(
|
||||
status: number,
|
||||
code: IssueThreadInteractionResolutionDenialCode,
|
||||
message: string,
|
||||
details: Record<string, unknown> = {},
|
||||
) {
|
||||
return new HttpError(status, message, { code, ...details });
|
||||
}
|
||||
|
||||
export function assertIssueThreadInteractionResolverAudience(
|
||||
input: IssueThreadInteractionResolverAudienceInput,
|
||||
) {
|
||||
const decision = evaluateIssueThreadInteractionResolverAudience(input);
|
||||
if (!decision.allowed) {
|
||||
throw issueThreadInteractionResolutionError(
|
||||
decision.status,
|
||||
decision.code,
|
||||
decision.message,
|
||||
{ effectiveResolverPolicy: decision.effectiveResolverPolicy, ...(decision.details ?? {}) },
|
||||
);
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves agent-owned attention with the same audience rules used by mutation
|
||||
* routes. Run attribution is deliberately represented by the interaction's
|
||||
* source run for the creator and by an opaque non-source run for other agents;
|
||||
* the real run is still authenticated at mutation time.
|
||||
*/
|
||||
export function issueThreadInteractionAttentionAgentAllowed(input: {
|
||||
agentId: string;
|
||||
interaction: IssueThreadInteractionResolverAudienceInput["interaction"];
|
||||
additionalRestriction?: IssueThreadInteractionResolverAudienceInput["additionalRestriction"];
|
||||
governedAction?: boolean;
|
||||
}) {
|
||||
const attentionRunId = input.interaction.createdByAgentId === input.agentId
|
||||
? input.interaction.sourceRunId ?? "attention-owner-creator-run"
|
||||
: "attention-owner-non-source-run";
|
||||
return evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: { type: "agent", agentId: input.agentId, runId: attentionRunId },
|
||||
interaction: input.interaction,
|
||||
additionalRestriction: input.additionalRestriction,
|
||||
governedAction: input.governedAction,
|
||||
}).allowed;
|
||||
}
|
||||
|
|
@ -86,14 +86,15 @@ describe("issueThreadInteractionService", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["ask_user_questions", undefined, {}, "board_or_agents", "board_or_agents"],
|
||||
["suggest_tasks", undefined, {}, "board_only", "board_only"],
|
||||
["request_confirmation", "board_or_agents", {}, "board_or_agents", "board_or_agents"],
|
||||
["request_checkbox_confirmation", undefined, { request_checkbox_confirmation: { defaultPolicy: "board_or_agents" } }, "board_or_agents", "board_or_agents"],
|
||||
["request_item_verdicts", "board_or_agents", { request_item_verdicts: { cap: "board_only" } }, "board_or_agents", "board_only"],
|
||||
["ask_user_questions", undefined, {}, "anyone", "anyone", "inherited", "requested"],
|
||||
["suggest_tasks", undefined, {}, "anyone", "anyone", "inherited", "requested"],
|
||||
["request_confirmation", "board_or_agents", {}, "anyone", "anyone", "explicit", "requested"],
|
||||
["request_confirmation", "board_only", {}, "human_only", "human_only", "explicit", "requested"],
|
||||
["request_checkbox_confirmation", undefined, { request_checkbox_confirmation: { defaultPolicy: "not_creator" } }, "not_creator", "not_creator", "inherited", "requested"],
|
||||
["request_item_verdicts", "anyone", { request_item_verdicts: { cap: "not_creator" } }, "anyone", "not_creator", "explicit", "company_cap"],
|
||||
] as const)(
|
||||
"resolves %s requested/default/cap policy snapshots",
|
||||
async (kind, requested, governance, expectedRequested, expectedEffective) => {
|
||||
async (kind, requested, governance, expectedRequested, expectedEffective, expectedProvenance, expectedSource) => {
|
||||
const { resolveInteractionPolicy } = await import("./issue-thread-interactions.js");
|
||||
expect(resolveInteractionPolicy({
|
||||
kind,
|
||||
|
|
@ -103,11 +104,13 @@ describe("issueThreadInteractionService", () => {
|
|||
})).toEqual({
|
||||
requestedResolverPolicy: expectedRequested,
|
||||
effectiveResolverPolicy: expectedEffective,
|
||||
resolverPolicyProvenance: expectedProvenance,
|
||||
effectiveResolverPolicySource: expectedSource,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("always clamps tool-action confirmations to board-only", async () => {
|
||||
it("always clamps tool-action confirmations to human-only", async () => {
|
||||
const { resolveInteractionPolicy } = await import("./issue-thread-interactions.js");
|
||||
expect(resolveInteractionPolicy({
|
||||
kind: "request_confirmation",
|
||||
|
|
@ -115,8 +118,10 @@ describe("issueThreadInteractionService", () => {
|
|||
governance: { request_confirmation: { defaultPolicy: "board_or_agents", cap: "board_or_agents" } },
|
||||
hasToolAction: true,
|
||||
})).toEqual({
|
||||
requestedResolverPolicy: "board_or_agents",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "governed_action",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -130,8 +135,10 @@ describe("issueThreadInteractionService", () => {
|
|||
kind: "suggest_tasks",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
requestedResolverPolicy: "board_only",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
idempotencyKey: "run-1:suggest",
|
||||
sourceCommentId: null,
|
||||
sourceRunId: "22222222-2222-4222-8222-222222222222",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
issues,
|
||||
toolActionRequests,
|
||||
} from "@paperclipai/db";
|
||||
import { trackInteractionResolved } from "@paperclipai/shared/telemetry";
|
||||
import { trackInteractionCreated, trackInteractionResolved } from "@paperclipai/shared/telemetry";
|
||||
import type {
|
||||
AcceptIssueThreadInteraction,
|
||||
AskUserQuestionsAnswer,
|
||||
|
|
@ -22,8 +22,11 @@ import type {
|
|||
InteractionResolverGovernance,
|
||||
IssueReviewPolicy,
|
||||
IssueThreadInteraction,
|
||||
IssueThreadInteractionCanonicalResolverPolicy,
|
||||
IssueThreadInteractionEffectiveResolverPolicySource,
|
||||
IssueThreadInteractionKind,
|
||||
IssueThreadInteractionResolverPolicy,
|
||||
IssueThreadInteractionResolverPolicyProvenance,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
RequestConfirmationTarget,
|
||||
|
|
@ -43,6 +46,8 @@ import {
|
|||
askUserQuestionsResultSchema,
|
||||
cancelIssueThreadInteractionSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
legacyIssueThreadInteractionResolverPolicyAlias,
|
||||
normalizeIssueThreadInteractionResolverPolicy,
|
||||
rejectIssueThreadInteractionSchema,
|
||||
requestCheckboxConfirmationPayloadSchema,
|
||||
requestCheckboxConfirmationResultSchema,
|
||||
|
|
@ -65,6 +70,12 @@ import {
|
|||
isIssueReviewVerdictInteraction,
|
||||
} from "./issue-review-policy.js";
|
||||
import { issueService, runWorkspaceIsFinalized } from "./issues.js";
|
||||
import {
|
||||
assertIssueThreadInteractionResolverAudience,
|
||||
canonicalizeStoredResolverPolicy,
|
||||
issueThreadInteractionResolutionError,
|
||||
type IssueThreadInteractionResolverRestriction,
|
||||
} from "./issue-thread-interaction-resolution.js";
|
||||
import {
|
||||
createPullRequestMergeStateResolver,
|
||||
extractGitHubPullRequestReferences,
|
||||
|
|
@ -81,7 +92,11 @@ type InteractionActor = {
|
|||
runId?: string | null;
|
||||
userId?: string | null;
|
||||
systemId?: string | null;
|
||||
reviewVerdictAuthorized?: boolean;
|
||||
resolverPolicyRestriction?:
|
||||
| IssueThreadInteractionCanonicalResolverPolicy
|
||||
| IssueThreadInteractionResolverRestriction
|
||||
| null;
|
||||
suggestedTaskEffectsAuthorized?: boolean;
|
||||
resolutionDetails?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
|
@ -227,12 +242,21 @@ type ResolvedInteractionResult = {
|
|||
type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect;
|
||||
type IssueTouchDb = Pick<Db, "update">;
|
||||
|
||||
const DEFAULT_RESOLVER_POLICY_BY_KIND: Record<IssueThreadInteractionKind, IssueThreadInteractionResolverPolicy> = {
|
||||
suggest_tasks: "board_only",
|
||||
ask_user_questions: "board_or_agents",
|
||||
request_confirmation: "board_only",
|
||||
request_checkbox_confirmation: "board_only",
|
||||
request_item_verdicts: "board_only",
|
||||
export const DEFAULT_RESOLVER_POLICY_BY_KIND: Record<
|
||||
IssueThreadInteractionKind,
|
||||
IssueThreadInteractionCanonicalResolverPolicy
|
||||
> = {
|
||||
suggest_tasks: "anyone",
|
||||
ask_user_questions: "anyone",
|
||||
request_confirmation: "anyone",
|
||||
request_checkbox_confirmation: "anyone",
|
||||
request_item_verdicts: "anyone",
|
||||
};
|
||||
|
||||
const RESOLVER_POLICY_RESTRICTION_RANK: Record<IssueThreadInteractionCanonicalResolverPolicy, number> = {
|
||||
anyone: 0,
|
||||
not_creator: 1,
|
||||
human_only: 2,
|
||||
};
|
||||
|
||||
export function resolveInteractionPolicy(args: {
|
||||
|
|
@ -242,47 +266,56 @@ export function resolveInteractionPolicy(args: {
|
|||
hasToolAction: boolean;
|
||||
}) {
|
||||
const kindGovernance = args.governance[args.kind];
|
||||
const requestedResolverPolicy = args.requested
|
||||
const requestedPolicyInput = args.requested
|
||||
?? kindGovernance?.defaultPolicy
|
||||
?? DEFAULT_RESOLVER_POLICY_BY_KIND[args.kind];
|
||||
const effectiveResolverPolicy = args.hasToolAction || kindGovernance?.cap === "board_only"
|
||||
? "board_only"
|
||||
: requestedResolverPolicy;
|
||||
return { requestedResolverPolicy, effectiveResolverPolicy } as const;
|
||||
const requestedResolverPolicy = normalizeIssueThreadInteractionResolverPolicy(requestedPolicyInput);
|
||||
const resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance =
|
||||
args.requested === undefined ? "inherited" : "explicit";
|
||||
|
||||
let effectiveResolverPolicy = requestedResolverPolicy;
|
||||
let effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource = "requested";
|
||||
if (args.hasToolAction) {
|
||||
effectiveResolverPolicy = "human_only";
|
||||
effectiveResolverPolicySource = "governed_action";
|
||||
} else if (kindGovernance?.cap) {
|
||||
const cap = normalizeIssueThreadInteractionResolverPolicy(kindGovernance.cap);
|
||||
if (RESOLVER_POLICY_RESTRICTION_RANK[cap] > RESOLVER_POLICY_RESTRICTION_RANK[effectiveResolverPolicy]) {
|
||||
effectiveResolverPolicy = cap;
|
||||
effectiveResolverPolicySource = "company_cap";
|
||||
}
|
||||
}
|
||||
return {
|
||||
requestedResolverPolicy,
|
||||
effectiveResolverPolicy,
|
||||
resolverPolicyProvenance,
|
||||
effectiveResolverPolicySource,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function assertAgentResolutionAllowed(current: IssueThreadInteractionRow, actor: InteractionActor) {
|
||||
if (!actor.agentId) return;
|
||||
if (!actor.runId) throw forbidden("Agent run id required to resolve an issue-thread interaction");
|
||||
if (
|
||||
current.kind === "request_confirmation"
|
||||
&& current.payload
|
||||
&& typeof current.payload === "object"
|
||||
&& "toolAction" in current.payload
|
||||
&& current.payload.toolAction !== undefined
|
||||
) {
|
||||
throw forbidden("Tool-action confirmations are always board-only");
|
||||
function resolverActor(actor: InteractionActor) {
|
||||
if (actor.systemId) return { type: "system" as const, systemId: actor.systemId };
|
||||
if (actor.agentId) {
|
||||
return { type: "agent" as const, agentId: actor.agentId, runId: actor.runId };
|
||||
}
|
||||
if (actor.reviewVerdictAuthorized && isRequestConfirmationLikeKind(current.kind)) {
|
||||
assertAgentInteractionActorAllowed(current, actor);
|
||||
return;
|
||||
}
|
||||
if (current.effectiveResolverPolicy !== "board_or_agents") {
|
||||
throw forbidden("This issue-thread interaction is board-only");
|
||||
}
|
||||
assertAgentInteractionActorAllowed(current, actor);
|
||||
if (actor.userId) return { type: "user" as const, userId: actor.userId };
|
||||
// Missing principals must fail closed. Internal maintenance paths that are
|
||||
// intentionally system-owned provide an explicit systemId.
|
||||
return { type: "agent" as const, agentId: null, runId: null };
|
||||
}
|
||||
|
||||
function assertAgentInteractionActorAllowed(current: IssueThreadInteractionRow, actor: InteractionActor) {
|
||||
if (current.addresseeAgentId && current.addresseeAgentId !== actor.agentId) {
|
||||
throw forbidden("Only the addressed agent or a board user may resolve this issue-thread interaction");
|
||||
}
|
||||
if (current.createdByAgentId === actor.agentId) {
|
||||
throw forbidden("Agents cannot resolve interactions they created");
|
||||
}
|
||||
if (current.sourceRunId && current.sourceRunId === actor.runId) {
|
||||
throw forbidden("Agents cannot resolve interactions created by the same run");
|
||||
}
|
||||
function assertInteractionResolutionAllowed(current: IssueThreadInteractionRow, actor: InteractionActor) {
|
||||
return assertIssueThreadInteractionResolverAudience({
|
||||
actor: resolverActor(actor),
|
||||
interaction: current,
|
||||
additionalRestriction: actor.resolverPolicyRestriction,
|
||||
governedAction:
|
||||
current.kind === "request_confirmation"
|
||||
&& current.payload !== null
|
||||
&& typeof current.payload === "object"
|
||||
&& "toolAction" in current.payload
|
||||
&& current.payload.toolAction !== undefined,
|
||||
});
|
||||
}
|
||||
|
||||
type IssueResolutionContext = {
|
||||
|
|
@ -310,15 +343,9 @@ async function assertRequestConfirmationResolutionAllowedUnderLock(
|
|||
&& isRequestConfirmationLikeKind(interaction.kind)
|
||||
&& await isIssueReviewVerdictInteraction(tx, { issue, interaction });
|
||||
|
||||
if (!isReviewVerdict) {
|
||||
assertAgentResolutionAllowed(interaction, {
|
||||
...actor,
|
||||
reviewVerdictAuthorized: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
assertInteractionResolutionAllowed(interaction, actor);
|
||||
if (!isReviewVerdict) return;
|
||||
|
||||
if (actor.agentId) assertAgentInteractionActorAllowed(interaction, actor);
|
||||
const verdictActor = actor.agentId
|
||||
? { type: "agent" as const, id: actor.agentId }
|
||||
: actor.userId
|
||||
|
|
@ -429,15 +456,33 @@ function parseStoredInteractionResult<S extends z.ZodTypeAny>(
|
|||
function hydrateInteraction(
|
||||
row: IssueThreadInteractionRow,
|
||||
): IssueThreadInteraction {
|
||||
const storedRequestedResolverPolicy = row.requestedResolverPolicy as IssueThreadInteractionResolverPolicy;
|
||||
const storedEffectiveResolverPolicy = row.effectiveResolverPolicy as IssueThreadInteractionResolverPolicy;
|
||||
const resolverPolicyProvenance = row.resolverPolicyProvenance
|
||||
?? (storedRequestedResolverPolicy === "board_only" || storedRequestedResolverPolicy === "board_or_agents"
|
||||
? "legacy_inherited_restriction"
|
||||
: "inherited");
|
||||
const canonicalizeStoredPolicy = (
|
||||
policy: IssueThreadInteractionResolverPolicy,
|
||||
): IssueThreadInteractionCanonicalResolverPolicy =>
|
||||
canonicalizeStoredResolverPolicy(policy, resolverPolicyProvenance);
|
||||
const requestedResolverPolicy = canonicalizeStoredPolicy(storedRequestedResolverPolicy);
|
||||
const effectiveResolverPolicy = canonicalizeStoredPolicy(storedEffectiveResolverPolicy);
|
||||
const base = {
|
||||
...row,
|
||||
idempotencyKey: row.idempotencyKey ?? null,
|
||||
addresseeAgentId: row.addresseeAgentId ?? null,
|
||||
status: row.status as IssueThreadInteraction["status"],
|
||||
continuationPolicy: row.continuationPolicy as IssueThreadInteraction["continuationPolicy"],
|
||||
resolverPolicy: row.requestedResolverPolicy,
|
||||
requestedResolverPolicy: row.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: row.effectiveResolverPolicy,
|
||||
resolverPolicy: requestedResolverPolicy,
|
||||
requestedResolverPolicy,
|
||||
effectiveResolverPolicy,
|
||||
resolverPolicyProvenance,
|
||||
effectiveResolverPolicySource: row.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: {
|
||||
requested: legacyIssueThreadInteractionResolverPolicyAlias(requestedResolverPolicy),
|
||||
effective: legacyIssueThreadInteractionResolverPolicyAlias(effectiveResolverPolicy),
|
||||
},
|
||||
};
|
||||
|
||||
switch (row.kind) {
|
||||
|
|
@ -492,6 +537,52 @@ function isTerminalIssueStatus(status: string) {
|
|||
return status === "done" || status === "cancelled";
|
||||
}
|
||||
|
||||
function interactionNotFoundError() {
|
||||
return notFound("Interaction not found", { code: "interaction_not_found" });
|
||||
}
|
||||
|
||||
function interactionIssueClosedError() {
|
||||
return issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_issue_closed",
|
||||
"Interaction is no longer actionable because the issue is closed",
|
||||
);
|
||||
}
|
||||
|
||||
function interactionAlreadyResolvedError() {
|
||||
return issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_already_resolved",
|
||||
"Interaction has already been resolved",
|
||||
);
|
||||
}
|
||||
|
||||
function interactionTerminalError(row: { status: string; result?: unknown }) {
|
||||
const result = row.result && typeof row.result === "object" && !Array.isArray(row.result)
|
||||
? row.result as unknown as Record<string, unknown>
|
||||
: null;
|
||||
if (result?.outcome === "stale_target") {
|
||||
return issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_stale_target",
|
||||
"Interaction target is stale",
|
||||
);
|
||||
}
|
||||
if (result?.outcome === "superseded_by_comment" || result?.outcome === "superseded_by_newer_request") {
|
||||
return issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_superseded",
|
||||
"Interaction has been superseded",
|
||||
);
|
||||
}
|
||||
if (result?.outcome === "issue_closed") return interactionIssueClosedError();
|
||||
return issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_already_resolved",
|
||||
"Interaction has already been resolved",
|
||||
);
|
||||
}
|
||||
|
||||
function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
|
||||
issue: IssueResolutionContext;
|
||||
current: IssueThreadInteractionRow;
|
||||
|
|
@ -827,12 +918,28 @@ async function emitInteractionResolvedTelemetry(
|
|||
...buildInteractionResolvedCounts(interaction, {
|
||||
createdTaskCount: args?.createdTaskCount,
|
||||
}),
|
||||
legacyInheritedRestriction:
|
||||
interaction.resolverPolicyProvenance === "legacy_inherited_restriction",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[paperclip] Failed to emit interaction.resolved telemetry", error);
|
||||
}
|
||||
}
|
||||
|
||||
function emitInteractionCreatedTelemetry(args: {
|
||||
interactionKind: IssueThreadInteractionKind;
|
||||
usedDeprecatedResolverPolicyAlias: boolean;
|
||||
}) {
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (!telemetryClient) return;
|
||||
|
||||
try {
|
||||
trackInteractionCreated(telemetryClient, args);
|
||||
} catch (error) {
|
||||
console.error("[paperclip] Failed to emit interaction.created telemetry", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function emitResolvedInteractionsTelemetry(
|
||||
db: Pick<Db, "select">,
|
||||
interactions: readonly IssueThreadInteraction[],
|
||||
|
|
@ -891,7 +998,7 @@ function buildTaskCreationOrder(tasks: ReadonlyArray<SuggestTasksInteraction["pa
|
|||
return ordered;
|
||||
}
|
||||
|
||||
function resolveSelectedSuggestedTasks(args: {
|
||||
export function resolveSelectedSuggestedTasks(args: {
|
||||
interaction: SuggestTasksInteraction;
|
||||
selectedClientKeys?: AcceptIssueThreadInteraction["selectedClientKeys"];
|
||||
}) {
|
||||
|
|
@ -967,10 +1074,6 @@ function resolveRequestItemVerdictSubmissions(args: {
|
|||
actor: InteractionActor;
|
||||
now: Date;
|
||||
}) {
|
||||
if (!args.actor.userId) {
|
||||
throw unprocessable("request_item_verdicts submissions require a user actor");
|
||||
}
|
||||
|
||||
const existingItems = args.interaction.result?.items ?? [];
|
||||
const existingById = new Map(existingItems.map((item) => [item.id, item] as const));
|
||||
const payloadItemIds = new Set(args.interaction.payload.items.map((item) => item.id));
|
||||
|
|
@ -1002,7 +1105,9 @@ function resolveRequestItemVerdictSubmissions(args: {
|
|||
id: submitted.id,
|
||||
verdict: submitted.verdict,
|
||||
...(reason ? { reason } : {}),
|
||||
resolvedByUserId: args.actor.userId,
|
||||
...(args.actor.userId ? { resolvedByUserId: args.actor.userId } : {}),
|
||||
...(args.actor.agentId ? { resolvedByAgentId: args.actor.agentId } : {}),
|
||||
...(args.actor.runId ? { resolvedByRunId: args.actor.runId } : {}),
|
||||
resolvedAt: args.now,
|
||||
});
|
||||
newlyResolvedItemIds.push(submitted.id);
|
||||
|
|
@ -1227,7 +1332,11 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
|
|||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_already_resolved",
|
||||
"Interaction has already been resolved",
|
||||
);
|
||||
}
|
||||
await touchIssue(db, args.row.issueId);
|
||||
const expired = hydrateInteraction(updated);
|
||||
|
|
@ -1289,7 +1398,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!current || current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
throw interactionNotFoundError();
|
||||
}
|
||||
return hydrateInteraction(current);
|
||||
}
|
||||
|
|
@ -1339,22 +1448,22 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.where(eq(issueThreadInteractions.id, args.interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!current) throw notFound("Interaction not found");
|
||||
if (!current) throw interactionNotFoundError();
|
||||
if (current.companyId !== args.issue.companyId || current.issueId !== args.issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
throw interactionNotFoundError();
|
||||
}
|
||||
if (args.issue.status && isTerminalIssueStatus(args.issue.status)) {
|
||||
throw conflict("Interaction is no longer actionable because the issue is closed");
|
||||
throw interactionIssueClosedError();
|
||||
}
|
||||
if (current.status !== "pending") {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionTerminalError(current);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function assertIssueOpenForInteractionResolution(issue: { id: string; companyId: string; status?: string }) {
|
||||
if (issue.status && isTerminalIssueStatus(issue.status)) {
|
||||
throw conflict("Interaction is no longer actionable because the issue is closed");
|
||||
throw interactionIssueClosedError();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1371,9 +1480,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
row: args.current,
|
||||
actor: args.actor,
|
||||
});
|
||||
if (expired) {
|
||||
return { interaction: expired, continuationIssue: null };
|
||||
}
|
||||
if (expired) throw interactionTerminalError({ status: expired.status, result: expired.result });
|
||||
|
||||
const now = new Date();
|
||||
const result = await db.transaction(async (tx) => {
|
||||
|
|
@ -1414,7 +1521,11 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
throw notFound("Interaction not found");
|
||||
}
|
||||
if (lockedCurrent.status !== "pending") {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_already_resolved",
|
||||
"Interaction has already been resolved",
|
||||
);
|
||||
}
|
||||
await assertRequestConfirmationResolutionAllowedUnderLock(
|
||||
tx as unknown as Db,
|
||||
|
|
@ -1453,7 +1564,11 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_already_resolved",
|
||||
"Interaction has already been resolved",
|
||||
);
|
||||
}
|
||||
|
||||
let continuationIssue: IssueWakeTarget | null = null;
|
||||
|
|
@ -1524,9 +1639,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
row: args.current,
|
||||
actor: args.actor,
|
||||
});
|
||||
if (expired) {
|
||||
return expired;
|
||||
}
|
||||
if (expired) throw interactionTerminalError({ status: expired.status, result: expired.result });
|
||||
|
||||
const interaction = hydrateInteraction(args.current) as RequestConfirmationLikeInteraction;
|
||||
const reason = args.input.reason?.trim() ?? "";
|
||||
|
|
@ -1569,7 +1682,11 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
throw notFound("Interaction not found");
|
||||
}
|
||||
if (lockedCurrent.status !== "pending") {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_already_resolved",
|
||||
"Interaction has already been resolved",
|
||||
);
|
||||
}
|
||||
await assertRequestConfirmationResolutionAllowedUnderLock(
|
||||
tx as unknown as Db,
|
||||
|
|
@ -1600,7 +1717,11 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.returning();
|
||||
|
||||
if (!resolved) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw issueThreadInteractionResolutionError(
|
||||
409,
|
||||
"interaction_already_resolved",
|
||||
"Interaction has already been resolved",
|
||||
);
|
||||
}
|
||||
await touchIssue(tx, args.issue.id);
|
||||
return resolved;
|
||||
|
|
@ -1905,6 +2026,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
actor: InteractionActor,
|
||||
) => {
|
||||
const data = normalizeCreateInteractionInput(createIssueThreadInteractionSchema.parse(input));
|
||||
const usedDeprecatedResolverPolicyAlias =
|
||||
data.resolverPolicy === "board_or_agents" || data.resolverPolicy === "board_only";
|
||||
const governance = await db
|
||||
.select({ interactionResolverGovernance: companies.interactionResolverGovernance })
|
||||
.from(companies)
|
||||
|
|
@ -2036,6 +2159,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
continuationPolicy: data.continuationPolicy,
|
||||
requestedResolverPolicy: policy.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: policy.effectiveResolverPolicy,
|
||||
resolverPolicyProvenance: policy.resolverPolicyProvenance,
|
||||
effectiveResolverPolicySource: policy.effectiveResolverPolicySource,
|
||||
idempotencyKey: data.idempotencyKey ?? null,
|
||||
sourceCommentId: data.sourceCommentId ?? null,
|
||||
sourceRunId: data.sourceRunId ?? null,
|
||||
|
|
@ -2117,7 +2242,12 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
if (superseded.length > 0) {
|
||||
await emitResolvedInteractionsTelemetry(db, superseded.map(hydrateInteraction));
|
||||
}
|
||||
return hydrateInteraction(created);
|
||||
const interaction = hydrateInteraction(created);
|
||||
emitInteractionCreatedTelemetry({
|
||||
interactionKind: interaction.kind,
|
||||
usedDeprecatedResolverPolicyAlias,
|
||||
});
|
||||
return interaction;
|
||||
},
|
||||
|
||||
acceptInteraction: async (
|
||||
|
|
@ -2128,7 +2258,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
): Promise<ResolvedInteractionResult> => {
|
||||
const data = acceptIssueThreadInteractionSchema.parse(input);
|
||||
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
||||
assertAgentResolutionAllowed(current, actor);
|
||||
assertInteractionResolutionAllowed(current, actor);
|
||||
switch (current.kind) {
|
||||
case "suggest_tasks":
|
||||
// Accepting suggest_tasks only creates follow-up issues; it does not
|
||||
|
|
@ -2181,16 +2311,23 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!current) throw notFound("Interaction not found");
|
||||
if (!current) throw interactionNotFoundError();
|
||||
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
throw interactionNotFoundError();
|
||||
}
|
||||
assertAgentResolutionAllowed(current, actor);
|
||||
assertInteractionResolutionAllowed(current, actor);
|
||||
if (current.kind !== "suggest_tasks") {
|
||||
throw unprocessable("Only suggest_tasks interactions can be accepted");
|
||||
}
|
||||
if (current.status !== "pending") {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionTerminalError(current);
|
||||
}
|
||||
if (actor.agentId && actor.suggestedTaskEffectsAuthorized !== true) {
|
||||
throw issueThreadInteractionResolutionError(
|
||||
403,
|
||||
"interaction_governed_action_denied",
|
||||
"Suggested-task creation requires independent task-creation authorization",
|
||||
);
|
||||
}
|
||||
|
||||
const interaction = hydrateInteraction(current) as SuggestTasksInteraction;
|
||||
|
|
@ -2244,7 +2381,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.returning();
|
||||
|
||||
if (!claimed) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionAlreadyResolvedError();
|
||||
}
|
||||
|
||||
for (const task of orderedTasks) {
|
||||
|
|
@ -2330,7 +2467,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
) => {
|
||||
const data = rejectIssueThreadInteractionSchema.parse(input);
|
||||
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
||||
assertAgentResolutionAllowed(current, actor);
|
||||
assertInteractionResolutionAllowed(current, actor);
|
||||
switch (current.kind) {
|
||||
case "suggest_tasks":
|
||||
return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current);
|
||||
|
|
@ -2363,14 +2500,15 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!current) throw notFound("Interaction not found");
|
||||
if (!current) throw interactionNotFoundError();
|
||||
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
throw interactionNotFoundError();
|
||||
}
|
||||
if (current.kind !== "request_item_verdicts") {
|
||||
throw unprocessable("Only request_item_verdicts interactions can receive item verdicts");
|
||||
}
|
||||
|
||||
assertInteractionResolutionAllowed(current, actor);
|
||||
const interaction = hydrateInteraction(current) as RequestItemVerdictsInteraction;
|
||||
if (current.status !== "pending") {
|
||||
if (current.status === "answered") {
|
||||
|
|
@ -2381,20 +2519,24 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
throw unprocessable(`Unknown item verdict id: ${submitted.id}`);
|
||||
}
|
||||
if (!resolvedIds.has(submitted.id)) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionTerminalError(current);
|
||||
}
|
||||
}
|
||||
return { interaction, newlyResolvedItemIds: [], resolved: false };
|
||||
}
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionTerminalError(current);
|
||||
}
|
||||
|
||||
const expired = await expireStaleRequestConfirmationTarget(tx, {
|
||||
row: current,
|
||||
actor,
|
||||
});
|
||||
if (expired) {
|
||||
return { interaction: expired, newlyResolvedItemIds: [], resolved: false };
|
||||
return {
|
||||
interaction: expired,
|
||||
newlyResolvedItemIds: [],
|
||||
resolved: false,
|
||||
terminalError: interactionTerminalError({ status: expired.status, result: expired.result }),
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
|
@ -2432,7 +2574,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionAlreadyResolvedError();
|
||||
}
|
||||
|
||||
await touchIssue(tx, issue.id);
|
||||
|
|
@ -2443,6 +2585,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
};
|
||||
});
|
||||
|
||||
if ("terminalError" in submission) throw submission.terminalError;
|
||||
if (submission.resolved) {
|
||||
await emitInteractionResolvedTelemetry(db, submission.interaction);
|
||||
}
|
||||
|
|
@ -2458,13 +2601,13 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
) => {
|
||||
assertIssueOpenForInteractionResolution(issue);
|
||||
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
throw interactionNotFoundError();
|
||||
}
|
||||
if (current.kind !== "suggest_tasks") {
|
||||
throw unprocessable("Only suggest_tasks interactions can be rejected");
|
||||
}
|
||||
if (current.status !== "pending") {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionTerminalError(current);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
|
|
@ -2488,7 +2631,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionAlreadyResolvedError();
|
||||
}
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
|
|
@ -2850,9 +2993,9 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!current || current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
throw interactionNotFoundError();
|
||||
}
|
||||
if (current.status !== "pending") throw conflict("Interaction has already been resolved");
|
||||
if (current.status !== "pending") throw interactionTerminalError(current);
|
||||
|
||||
const reason = data.reason?.trim() || null;
|
||||
const now = new Date();
|
||||
|
|
@ -2899,7 +3042,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
if (!row) throw conflict("Interaction has already been resolved");
|
||||
if (!row) throw interactionAlreadyResolvedError();
|
||||
return row;
|
||||
});
|
||||
|
||||
|
|
@ -2922,16 +3065,16 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!current) throw notFound("Interaction not found");
|
||||
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
}
|
||||
assertAgentResolutionAllowed(current, actor);
|
||||
if (!current) throw interactionNotFoundError();
|
||||
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw interactionNotFoundError();
|
||||
}
|
||||
assertInteractionResolutionAllowed(current, actor);
|
||||
if (current.kind !== "ask_user_questions") {
|
||||
throw unprocessable("Only ask_user_questions interactions can be answered");
|
||||
}
|
||||
if (current.status !== "pending") {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionTerminalError(current);
|
||||
}
|
||||
|
||||
const interaction = hydrateInteraction(current) as AskUserQuestionsInteraction;
|
||||
|
|
@ -2949,8 +3092,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
answers: normalizedAnswers,
|
||||
summaryMarkdown: input.summaryMarkdown ?? null,
|
||||
},
|
||||
resolvedByAgentId: actor.agentId ?? null,
|
||||
resolvedByRunId: actor.runId ?? null,
|
||||
resolvedByAgentId: actor.agentId ?? null,
|
||||
resolvedByRunId: actor.runId ?? null,
|
||||
resolvedByUserId: actor.userId ?? null,
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
|
@ -2962,7 +3105,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionAlreadyResolvedError();
|
||||
}
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
|
|
@ -2985,15 +3128,15 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!current) throw notFound("Interaction not found");
|
||||
if (!current) throw interactionNotFoundError();
|
||||
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
throw interactionNotFoundError();
|
||||
}
|
||||
if (current.kind !== "ask_user_questions") {
|
||||
throw unprocessable("Only ask_user_questions interactions can be cancelled");
|
||||
}
|
||||
if (current.status !== "pending") {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionTerminalError(current);
|
||||
}
|
||||
|
||||
const reason = data.reason?.trim() || null;
|
||||
|
|
@ -3021,7 +3164,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
throw interactionAlreadyResolvedError();
|
||||
}
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ import {
|
|||
type ActivityPublication,
|
||||
} from "./activity-log.js";
|
||||
import { buildIssueChanges } from "./issue-change-receipt.js";
|
||||
import { issueThreadInteractionAttentionAgentAllowed } from "./issue-thread-interaction-resolution.js";
|
||||
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
|
||||
|
|
@ -2923,6 +2924,11 @@ async function listIssueReviewAttentionMap(
|
|||
issueId: issueThreadInteractions.issueId,
|
||||
status: issueThreadInteractions.status,
|
||||
kind: issueThreadInteractions.kind,
|
||||
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
||||
sourceRunId: issueThreadInteractions.sourceRunId,
|
||||
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
||||
effectiveResolverPolicy: issueThreadInteractions.effectiveResolverPolicy,
|
||||
resolverPolicyProvenance: issueThreadInteractions.resolverPolicyProvenance,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
|
|
@ -3046,32 +3052,56 @@ async function listIssueReviewAttentionMap(
|
|||
: [];
|
||||
const userNameById = new Map((userRows as Array<{ id: string; name: string }>).map((user) => [user.id, user.name]));
|
||||
const interactionKindById = new Map((interactionRows as Array<{ id: string; kind: string }>).map((row) => [row.id, row.kind]));
|
||||
const interactionAudienceById = new Map((interactionRows as Array<{
|
||||
id: string;
|
||||
createdByAgentId: string | null;
|
||||
sourceRunId: string | null;
|
||||
addresseeAgentId: string | null;
|
||||
effectiveResolverPolicy: string;
|
||||
resolverPolicyProvenance: string | null;
|
||||
}>).map((row) => [row.id, row]));
|
||||
const wakeReasonById = new Map((wakeRows as Array<{ id: string; reason: string | null }>).map((row) => [row.id, row.reason]));
|
||||
|
||||
for (const issue of reviewIssues) {
|
||||
const pathFacts = classifyIssueReviewPaths(livenessInput, livenessInput.issues.find((entry) => entry.id === issue.id)!);
|
||||
const paths: IssueReviewAttentionPath[] = pathFacts.map((path) => ({
|
||||
kind: path.kind,
|
||||
label: reviewPathLabel(
|
||||
path.kind,
|
||||
path.kind === "interaction" && path.ref
|
||||
? interactionKindById.get(path.ref) ?? null
|
||||
: path.kind === "queued_wake" && path.ref
|
||||
? wakeReasonById.get(path.ref) ?? null
|
||||
: null,
|
||||
),
|
||||
responder: path.agentId
|
||||
? agentNameById.get(path.agentId) ?? path.agentId
|
||||
: path.userId
|
||||
? userNameById.get(path.userId) ?? path.userId
|
||||
: path.kind === "interaction" || path.kind === "approval"
|
||||
? "Board"
|
||||
: null,
|
||||
since: path.since
|
||||
? (path.since instanceof Date ? path.since : new Date(path.since)).toISOString()
|
||||
: issue.updatedAt.toISOString(),
|
||||
ref: path.ref,
|
||||
}));
|
||||
const paths: IssueReviewAttentionPath[] = pathFacts.map((path) => {
|
||||
const interactionAudience = path.kind === "interaction" && path.ref
|
||||
? interactionAudienceById.get(path.ref) ?? null
|
||||
: null;
|
||||
const candidateAgentId = interactionAudience?.addresseeAgentId ?? issue.assigneeAgentId;
|
||||
const interactionResponderAgentId = interactionAudience
|
||||
&& candidateAgentId
|
||||
&& issueThreadInteractionAttentionAgentAllowed({
|
||||
agentId: candidateAgentId,
|
||||
interaction: interactionAudience,
|
||||
})
|
||||
? candidateAgentId
|
||||
: null;
|
||||
return {
|
||||
kind: path.kind,
|
||||
label: reviewPathLabel(
|
||||
path.kind,
|
||||
path.kind === "interaction" && path.ref
|
||||
? interactionKindById.get(path.ref) ?? null
|
||||
: path.kind === "queued_wake" && path.ref
|
||||
? wakeReasonById.get(path.ref) ?? null
|
||||
: null,
|
||||
),
|
||||
responder: path.agentId
|
||||
? agentNameById.get(path.agentId) ?? path.agentId
|
||||
: path.userId
|
||||
? userNameById.get(path.userId) ?? path.userId
|
||||
: path.kind === "interaction" && interactionResponderAgentId
|
||||
? agentNameById.get(interactionResponderAgentId) ?? interactionResponderAgentId
|
||||
: path.kind === "interaction" || path.kind === "approval"
|
||||
? "Board"
|
||||
: null,
|
||||
since: path.since
|
||||
? (path.since instanceof Date ? path.since : new Date(path.since)).toISOString()
|
||||
: issue.updatedAt.toISOString(),
|
||||
ref: path.ref,
|
||||
};
|
||||
});
|
||||
|
||||
if (paths.length > 0) {
|
||||
result.set(issue.id, {
|
||||
|
|
|
|||
|
|
@ -690,7 +690,11 @@ export function buildHostServices(
|
|||
pluginKey: string,
|
||||
eventBus: PluginEventBus,
|
||||
notifyWorker?: (method: string, params: unknown) => void,
|
||||
options: { pluginWorkerManager?: PluginWorkerManager; manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1 } = {},
|
||||
options: {
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1;
|
||||
heartbeatRuntimeEnv?: Record<string, string | undefined>;
|
||||
} = {},
|
||||
): HostServices & { dispose(): void } {
|
||||
const registry = pluginRegistryService(db);
|
||||
const stateStore = pluginStateStore(db);
|
||||
|
|
@ -730,6 +734,7 @@ export function buildHostServices(
|
|||
});
|
||||
const heartbeat = heartbeatService(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
runtimeEnv: options.heartbeatRuntimeEnv,
|
||||
});
|
||||
const projects = projectService(db);
|
||||
const executionWorkspaces = executionWorkspaceService(db);
|
||||
|
|
|
|||
|
|
@ -710,7 +710,7 @@ function watchdogWakeContext(input: {
|
|||
"reassign_watched_subtree_issues",
|
||||
"create_child_issues_under_non_watchdog_watched_subtree",
|
||||
"create_product_bug_followups_outside_watched_subtree",
|
||||
"resolve_eligible_request_confirmation_plan_interactions",
|
||||
"resolve_issue_thread_interactions_through_ordinary_audience_policy",
|
||||
"update_reusable_watchdog_issue",
|
||||
],
|
||||
deniedOperations: [
|
||||
|
|
@ -718,7 +718,7 @@ function watchdogWakeContext(input: {
|
|||
"create_product_bug_followups_as_source_tree_children",
|
||||
"mutate_task_watchdog_descendants",
|
||||
"mutate_outside_watched_subtree",
|
||||
"resolve_board_only_or_security_sensitive_approvals",
|
||||
"resolve_human_only_interactions_or_security_sensitive_approvals",
|
||||
"create_nested_task_watchdogs",
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -233,29 +233,32 @@ POST /api/companies/{companyId}/approvals
|
|||
|
||||
## Issue-Thread Interactions
|
||||
|
||||
Issue-thread interactions are first-class cards that render in the issue thread and capture a typed board/user response. Use them instead of asking the board to type yes/no or a checklist in markdown — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path.
|
||||
Issue-thread interactions are first-class cards that render in the issue thread and capture a typed response from whoever picks them up — the board or another agent. Use them instead of asking for a yes/no or a checklist in markdown prose — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path.
|
||||
|
||||
A card is a coordination record, not a grant of authority. Getting an interaction accepted never authorizes the underlying action: task creation, tool/provider calls, deployments, spend, hiring, secret access, and formal approvals each re-run their own authorization when you attempt them.
|
||||
|
||||
Five issue-thread interaction kinds are supported. Pick the smallest kind that fits the decision shape:
|
||||
|
||||
| Kind | When to use | When **not** to use |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `request_confirmation` | Single yes/no decision bound to a target (e.g. accept a plan revision, approve a launch). | Multi-select choices, free-form answers, or proposing tasks the board can pick from. |
|
||||
| `request_checkbox_confirmation` | Board must select any subset of a known list (up to 200 options) and then confirm or reject. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). |
|
||||
| `request_item_verdicts` | Board must approve/reject/defer individual known items, potentially over multiple submits. | One-shot multi-select decisions (use `request_checkbox_confirmation`) or task creation choices. |
|
||||
| `request_confirmation` | Single yes/no decision bound to a target (e.g. accept a plan revision, approve a launch). | Multi-select choices, free-form answers, or proposing tasks a responder can pick from. |
|
||||
| `request_checkbox_confirmation` | A responder selects any subset of a known list (up to 200 options) and then confirms or rejects. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). |
|
||||
| `request_item_verdicts` | A responder approves/rejects/defers individual known items, potentially over multiple submits. | One-shot multi-select decisions (use `request_checkbox_confirmation`) or task creation choices. |
|
||||
| `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. |
|
||||
| `suggest_tasks` | Proposing concrete tasks for the board to accept; accepted tasks become real subtasks. | Asking the board to confirm a plan or arbitrary selection. Tasks are the unit; not arbitrary ids. |
|
||||
| `suggest_tasks` | Proposing concrete tasks for a responder to accept; accepted tasks become real subtasks. | Confirming a plan or an arbitrary selection. Tasks are the unit; not arbitrary ids. |
|
||||
| `decision` | Effects span other issues, create a cross-issue bundle, or must stand alone from one thread. | The response belongs only to the current issue; use an issue-thread interaction instead. |
|
||||
|
||||
Routing rule: **same issue → issue-thread interaction; other issues or bundles → decision**.
|
||||
|
||||
Key shared semantics:
|
||||
|
||||
- **Continuation policy.** `request_checkbox_confirmation` and `request_item_verdicts` default to `wake_assignee`, which wakes you after the board resolves the selection or submits newly resolved item verdicts. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume.
|
||||
- **Resolver audience.** Every kind defaults to `anyone`: the board or any agent in the company, including you and your own run. **Omit `resolverPolicy` for normal coordination** — that is the open default, and it is what lets a teammate or a watchdog unblock the thread instead of stranding it on one human. Ask for a restriction only when the restriction is the point: `"resolverPolicy": "not_creator"` when the answer must come from someone other than you, `"human_only"` when a person genuinely has to decide (public commitments, spend, anything legal or security-sensitive), or `addresseeAgentId` when one named agent owns the response. Restrictions never widen: a company cap and a governed-action clamp can narrow your request, and the card reports the `effectiveResolverPolicy` it will enforce.
|
||||
- **Continuation policy.** `request_checkbox_confirmation` and `request_item_verdicts` default to `wake_assignee`, which wakes you after the card is resolved or newly resolved item verdicts are submitted. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume.
|
||||
- **Target binding and staleness.** `request_confirmation`, `request_checkbox_confirmation`, and `request_item_verdicts` accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction.
|
||||
- **Supersede on user comment.** Target-bound request kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required.
|
||||
- **Withdraw and terminal expiry.** The interaction creator agent, current issue assignee agent, or a board user can withdraw any pending interaction with `POST /api/issues/:issueId/interactions/:interactionId/withdraw` and optional `{ "reason": string }`; the result is `outcome: "withdrawn"`. Closing an issue as `done` or `cancelled` expires all remaining pending interactions with `outcome: "issue_closed"` and never wakes the closed issue.
|
||||
- **Idempotency.** Use a deterministic `idempotencyKey` such as `confirmation:${issueId}:plan:${revisionId}` or `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries do not stack duplicate cards.
|
||||
- **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names what the board must decide. When a `request_confirmation` or `request_checkbox_confirmation` is the issue review request, include its returned id as `reviewInteractionId` in that PATCH. This explicit binding lets policy-eligible agents submit the review verdict without granting the same authority to unrelated pending confirmations. The pending interaction is the explicit waiting path.
|
||||
- **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names the response you are waiting for and who can give it (anyone by default, or the restriction you asked for). When a `request_confirmation` or `request_checkbox_confirmation` is the issue review request, include its returned id as `reviewInteractionId` in that PATCH. This explicit binding lets policy-eligible agents submit the review verdict without granting the same authority to unrelated pending confirmations. The pending interaction is the explicit waiting path.
|
||||
|
||||
### Standalone Decisions
|
||||
|
||||
|
|
@ -321,7 +324,7 @@ Bundle related cross-issue decisions with `POST /api/companies/{companyId}/decis
|
|||
|
||||
Bundles accept 1–50 decisions and are created atomically. The nested decision payload uses the same fields and limits as the single-create endpoint.
|
||||
|
||||
Create a `request_checkbox_confirmation` (board selects any subset, then confirms):
|
||||
Create a `request_checkbox_confirmation` (the responder selects any subset, then confirms):
|
||||
|
||||
```json
|
||||
POST /api/issues/{issueId}/interactions
|
||||
|
|
@ -357,7 +360,7 @@ POST /api/issues/{issueId}/interactions
|
|||
}
|
||||
```
|
||||
|
||||
When the board accepts, your wake delivers `result.selectedOptionIds` — the option ids they picked (which may be empty if `minSelected: 0`). Rejection delivers `result.reason` and a `commentId`.
|
||||
When it is accepted, your wake delivers `result.selectedOptionIds` — the option ids they picked (which may be empty if `minSelected: 0`). Rejection delivers `result.reason` and a `commentId`.
|
||||
|
||||
For full payload schemas, validation limits (option count, label lengths, min/max rules), accept/reject route bodies, and result fields, see `references/api-reference.md` -> **Checkbox confirmations**.
|
||||
|
||||
|
|
@ -401,7 +404,7 @@ POST /api/issues/{issueId}/interactions
|
|||
}
|
||||
```
|
||||
|
||||
The board submits verdicts with `POST /api/issues/{issueId}/interactions/{interactionId}/verdicts`. Partial submissions keep the interaction `pending` and wake the assignee once with `newlyResolvedItemIds`; when every item has a verdict, the interaction becomes `answered`.
|
||||
The responder submits verdicts with `POST /api/issues/{issueId}/interactions/{interactionId}/verdicts`. Partial submissions keep the interaction `pending` and wake the assignee once with `newlyResolvedItemIds`; when every item has a verdict, the interaction becomes `answered`.
|
||||
|
||||
## Niche Workflow Pointers
|
||||
|
||||
|
|
|
|||
|
|
@ -907,9 +907,12 @@ POST /api/issues/{issueId}/interactions
|
|||
|
||||
Resolver governance:
|
||||
|
||||
- Create accepts optional `resolverPolicy: "board_only" | "board_or_agents"`. If omitted, the company per-kind default applies (`ask_user_questions` defaults to `board_or_agents`; every other kind defaults to `board_only`). The response snapshots immutable `requestedResolverPolicy` and `effectiveResolverPolicy`; later governance edits never widen an existing pending card. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance` keyed by kind, with optional `defaultPolicy` and `cap`; a `board_only` cap always wins.
|
||||
- **Omit `resolverPolicy` for a normal interaction.** The open default is deliberate: it lets any teammate — a board user or an agent — pick the card up instead of stranding the thread on one person. Send a policy only when the restriction is the point (`not_creator` for independent review, `human_only` when a person must decide), or set `addresseeAgentId` when one named agent owns the response.
|
||||
- Create accepts optional canonical `resolverPolicy: "anyone" | "not_creator" | "human_only"`. Every interaction kind defaults to `anyone` when omitted. Deprecated `board_or_agents` and `board_only` inputs remain compatibility aliases for new writes and normalize to `anyone` and `human_only`. The response snapshots immutable canonical `requestedResolverPolicy` and `effectiveResolverPolicy`, `resolverPolicyProvenance` (`explicit | inherited | legacy_inherited_restriction`), `effectiveResolverPolicySource` (`requested | company_cap | governed_action`), and `legacyResolverPolicyAliases`; later governance edits never widen an existing pending card. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance` keyed by kind, with optional `defaultPolicy` and `cap`; a cap can narrow but never widen the requested audience.
|
||||
- Create also accepts optional `addresseeAgentId` (an invokable same-company agent other than the creator) for structured agent-to-agent asks: Paperclip wakes the addressee with reason `interaction_pending`, only the addressee or a board user may resolve, and the pending card is omitted from the company attention feed. Not allowed with `request_confirmation.payload.toolAction` (`400`).
|
||||
- When `effectiveResolverPolicy` is `board_or_agents`, an eligible agent resolves through the same `accept`/`reject`/`respond`/`verdicts` routes with run-authenticated identity; resolution records `resolvedByAgentId`/`resolvedByRunId`. The resolver cannot be the creator agent or source run, low-trust and watchdog-scoped actors are denied, and `payload.toolAction` confirmations stay board-only regardless of policy.
|
||||
- Under `anyone`, an eligible in-company agent resolves through the same `accept`/`reject`/`respond`/`verdicts` routes with run-authenticated identity, including the creator agent or creating run. `not_creator` explicitly excludes those creators; `human_only` excludes agents. Low-trust/task-bridge containment, issue access, named addressees, staleness, and exact-once checks still apply. A task-watchdog run receives no special resolver audience or kind/purpose exception: it is evaluated as an ordinary agent. `payload.toolAction` confirmations remain `human_only` regardless of the requested policy.
|
||||
- Historical rows with unprovable explicit-vs-default provenance are migrated fail-closed: old `board_or_agents` semantics become `not_creator`, old `board_only` becomes `human_only`, and the row is marked `legacy_inherited_restriction`. Resolved outcomes and attribution are not rewritten.
|
||||
- Resolution records a response only. Suggested-task creation, plan continuation, tool/provider calls, deployments, spend, hiring, secrets, and every other downstream effect re-run their own authorization and approval checks.
|
||||
|
||||
Rules:
|
||||
|
||||
|
|
@ -917,7 +920,7 @@ Rules:
|
|||
- Rejection does not wake the assignee by default. The board/user can add a normal comment when revisions are needed.
|
||||
- Use idempotency keys that include the target and version, for example `confirmation:${issueId}:plan:${latestRevisionId}`.
|
||||
- Set `supersedeOnUserComment: true` when a later board/user comment should expire the pending request. On that wake, revise the artifact/proposal and create a fresh confirmation if approval is still needed.
|
||||
- A pending interaction is an explicit waiting path. Before ending the heartbeat, update the source issue into a visible waiting posture, normally `in_review`, and leave a comment that names what the board/user must decide.
|
||||
- A pending interaction is an explicit waiting path. Before ending the heartbeat, update the source issue into a visible waiting posture, normally `in_review`, and leave a comment that names the response needed and the effective audience.
|
||||
- For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, set the source issue to `in_review`, and wait for acceptance before creating implementation subtasks.
|
||||
|
||||
### Checkbox confirmations
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
|
|
@ -826,6 +827,165 @@ describe("AttentionQueueRow", () => {
|
|||
expect(gallery?.querySelectorAll("a")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// PAP-17287: the collapsed row must say who may resolve an interaction before
|
||||
// its compact verbs are used, and a denial must keep the server's reason.
|
||||
function interactionItem(
|
||||
audience: AttentionItem["resolverAudience"],
|
||||
verbs: AttentionItem["decisionVerbs"] = [
|
||||
{ id: "accept", label: "Accept", description: null },
|
||||
{ id: "reject", label: "Reject", description: null },
|
||||
],
|
||||
) {
|
||||
return buildItem({
|
||||
sourceKind: "issue_thread_interaction",
|
||||
subject: {
|
||||
kind: "interaction",
|
||||
id: "interaction-1",
|
||||
companyId: "c1",
|
||||
title: "Close this confirmation?",
|
||||
identifier: null,
|
||||
status: "pending",
|
||||
href: "/PAP/issues/issue-1#interaction-interaction-1",
|
||||
metadata: { kind: "request_confirmation", issueId: "issue-1" },
|
||||
},
|
||||
decisionVerbs: verbs,
|
||||
resolverAudience: audience,
|
||||
});
|
||||
}
|
||||
|
||||
const openAudience: AttentionItem["resolverAudience"] = {
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
addresseeAgentId: null,
|
||||
addresseeName: null,
|
||||
createdByAgentId: "agent-watchdog",
|
||||
createdByAgentName: "Watchdog",
|
||||
};
|
||||
|
||||
it("states the server-derived audience before the compact decision verbs", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={interactionItem(openAudience)}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const audience = container?.querySelector('[data-testid="interaction-audience"]');
|
||||
expect(audience?.getAttribute("data-audience-policy")).toBe("anyone");
|
||||
expect(audience?.getAttribute("data-audience-open")).toBe("true");
|
||||
expect(audience?.textContent).toContain("Anyone");
|
||||
expect(audience?.textContent).toContain("Anyone can respond");
|
||||
|
||||
// Reading order is the point: the audience qualifies the buttons, so it has
|
||||
// to precede them in the DOM as well as on screen.
|
||||
const actions = container?.querySelector('[aria-label="Decision actions"]');
|
||||
expect(audience && actions && audience.compareDocumentPosition(actions))
|
||||
.toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it("names the addressed responder on a restricted collapsed row", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={interactionItem({
|
||||
...openAudience,
|
||||
addresseeAgentId: "agent-codex",
|
||||
addresseeName: "CodexCoder",
|
||||
})}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const audience = container?.querySelector('[data-testid="interaction-audience"]');
|
||||
expect(audience?.getAttribute("data-audience-open")).toBe("false");
|
||||
expect(audience?.textContent).toContain("Only CodexCoder or the board can respond");
|
||||
// A collapsed row spends its line on the responder, not on a badge that
|
||||
// repeats the clause beside it.
|
||||
expect(audience?.textContent).not.toContain("Addressed —");
|
||||
});
|
||||
|
||||
// PAP-17289: the row's shell is `overflow-hidden`, so a clause that cannot
|
||||
// wrap is cut mid-word with no ellipsis and names a responder that does not
|
||||
// exist. jsdom does no layout, so this asserts the wrapping rule itself.
|
||||
it("lets a long addressee name wrap instead of being cut mid-word", () => {
|
||||
const addresseeName = "ReleaseEngineeringPlatformCoordinationServiceBot";
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={interactionItem({
|
||||
...openAudience,
|
||||
addresseeAgentId: "agent-release",
|
||||
addresseeName,
|
||||
})}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const audience = container?.querySelector('[data-testid="interaction-audience"]');
|
||||
const summary = audience?.querySelector('[data-testid="interaction-audience-summary"]');
|
||||
expect(summary?.textContent).toBe(`Only ${addresseeName} or the board can respond`);
|
||||
expect(summary?.parentElement?.className).toContain("break-words");
|
||||
});
|
||||
|
||||
it("renders no audience line when the feed carries no resolver metadata", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={interactionItem(null)}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Never guess a policy client-side: silence beats a wrong audience.
|
||||
expect(container?.querySelector('[data-testid="interaction-audience"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the server denial reason and names the responder when a compact accept is refused", async () => {
|
||||
vi.mocked(issuesApi.acceptInteraction).mockRejectedValue(
|
||||
new ApiError("This issue-thread interaction is human-only", 403, {
|
||||
error: "This issue-thread interaction is human-only",
|
||||
code: "interaction_human_only",
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={interactionItem({
|
||||
...openAudience,
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
})}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const accept = Array.from(container?.querySelectorAll("button") ?? []).find(
|
||||
(button) => button.textContent === "Accept",
|
||||
);
|
||||
act(() => accept?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const feedback = document.body.textContent ?? "";
|
||||
expect(feedback).toContain("This issue-thread interaction is human-only.");
|
||||
expect(feedback).toContain("Only the board can respond.");
|
||||
expect(feedback).not.toMatch(/try again/i);
|
||||
});
|
||||
|
||||
it("does not surface training state or actions for decisions", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { approvalsApi } from "../api/approvals";
|
|||
import { issuesApi } from "../api/issues";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { describeAttentionResolverAudience, type InteractionAudienceDescription } from "../lib/interaction-audience";
|
||||
import { interactionResolutionErrorMessage } from "../lib/interaction-resolution-error";
|
||||
import {
|
||||
attentionDetailImages,
|
||||
attentionDetailLine,
|
||||
|
|
@ -30,6 +32,7 @@ import {
|
|||
} from "../lib/attention";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { DecisionTriageStrip } from "./DecisionTriageStrip";
|
||||
import { InteractionAudienceLine } from "./InteractionAudienceLine";
|
||||
import { StatusGlyph } from "./StatusGlyph";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent } from "./ui/collapsible";
|
||||
|
|
@ -156,6 +159,11 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
// Which rows contribute an action bar. Inline rows carry compact decision
|
||||
// verbs; deep-link rows carry an Open button; curtain rows carry Restore.
|
||||
const compactActions = !isHidden ? collectCompactActions(item) : [];
|
||||
// Who the server will let resolve this interaction. A collapsed row offers
|
||||
// Accept/Reject before anything fetches the interaction, so the audience
|
||||
// travels with the feed item; null for every non-interaction source and for a
|
||||
// feed built before the metadata existed (PAP-17287).
|
||||
const audience = describeAttentionResolverAudience(item);
|
||||
const showOpen = !inline && !!href;
|
||||
const showRestore = isHidden && !!onRestore;
|
||||
// An expanded inline row hands its footer to the resolver, which owns the
|
||||
|
|
@ -196,7 +204,12 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
|
||||
<div className="flex flex-wrap items-center gap-2 @xl:justify-end">
|
||||
{showCompact && (
|
||||
<CompactDecisionActions item={item} companyId={companyId} onOpen={() => onToggleExpand(item)} />
|
||||
<CompactDecisionActions
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
audience={audience}
|
||||
onOpen={() => onToggleExpand(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOpen && (
|
||||
|
|
@ -353,6 +366,10 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
<CollapsibleContent data-decision-disclosure className="-mt-4">
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
{hasImages && <ThumbnailStack images={images} />}
|
||||
{/* The audience reads *before* the verbs it qualifies: a compact
|
||||
Accept sitting alone asks for a decision without saying whose
|
||||
it is (PAP-17287). */}
|
||||
{audience && <InteractionAudienceLine audience={audience} variant="compact" />}
|
||||
{inline && renderFooter({ compact: true })}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
|
@ -458,10 +475,13 @@ function collectCompactActions(item: AttentionItem): CompactAction[] {
|
|||
function CompactDecisionActions({
|
||||
item,
|
||||
companyId,
|
||||
audience,
|
||||
onOpen,
|
||||
}: {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
/** Effective resolver audience, so a denial can name who *can* respond. */
|
||||
audience: InteractionAudienceDescription | null;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -501,9 +521,11 @@ function CompactDecisionActions({
|
|||
});
|
||||
},
|
||||
onError: (error, action) => {
|
||||
// A policy denial is permanent, so it keeps the server's reason and names
|
||||
// the real responder instead of asking for a retry that will fail again.
|
||||
pushToast({
|
||||
title: `Could not ${decisionLabel(action)}`,
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
body: interactionResolutionErrorMessage(error, audience),
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { Users } from "lucide-react";
|
||||
import type { InteractionAudienceDescription } from "../lib/interaction-audience";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
/**
|
||||
* Who may resolve an interaction, stated before anyone acts.
|
||||
*
|
||||
* One component for both surfaces that ask for a decision: the full
|
||||
* issue-thread card (`variant="card"`) and a collapsed attention row sitting
|
||||
* directly above compact Accept/Reject buttons (`variant="compact"`,
|
||||
* PAP-17287). The compact form trades the full sentence for a glanceable clause
|
||||
* and hands the long form to the title attribute, so the two surfaces can never
|
||||
* describe the same policy differently.
|
||||
*
|
||||
* Presentation only: the description is derived from the server's evaluated
|
||||
* audience, and nothing here enables or disables a control. The resolution
|
||||
* routes re-check authority at use time.
|
||||
*/
|
||||
export function InteractionAudienceLine({
|
||||
audience,
|
||||
variant = "card",
|
||||
className,
|
||||
}: {
|
||||
audience: InteractionAudienceDescription;
|
||||
variant?: "card" | "compact";
|
||||
className?: string;
|
||||
}) {
|
||||
const compact = variant === "compact";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 text-xs leading-5 text-muted-foreground",
|
||||
compact ? "min-w-0" : "max-w-3xl",
|
||||
className,
|
||||
)}
|
||||
data-testid="interaction-audience"
|
||||
data-audience-policy={audience.policy}
|
||||
data-audience-open={audience.isOpen ? "true" : "false"}
|
||||
data-audience-variant={variant}
|
||||
title={compact
|
||||
? [audience.summary, audience.narrowedNote].filter(Boolean).join(" ")
|
||||
: undefined}
|
||||
>
|
||||
<Users className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
{/* The clause names an agent, and an agent name is one unbreakable word.
|
||||
Without a break the row's `overflow-hidden` shell cuts it mid-word with
|
||||
no ellipsis, so a reader sees a plausible but *wrong* responder
|
||||
("Only ReleaseEngineeringPlatformCoordin") and nothing signals the
|
||||
truncation. Wrapping is the only safe failure mode for a sentence whose
|
||||
whole job is to name who may act (PAP-17289). */}
|
||||
<div className="min-w-0 break-words">
|
||||
{/* The compact form drops the policy badge: its clause already names the
|
||||
responder, and "Anyone — Anyone can respond" spends a scarce line on
|
||||
saying one thing twice. The badge stays on the card, where the longer
|
||||
sentence needs a heading. */}
|
||||
{compact ? null : (
|
||||
<>
|
||||
<span className="font-medium text-foreground">{audience.label}</span>
|
||||
{" — "}
|
||||
</>
|
||||
)}
|
||||
<span data-testid="interaction-audience-summary">
|
||||
{compact ? audience.shortSummary : audience.summary}
|
||||
</span>
|
||||
{/* The reason an audience is narrower than the requester asked for is
|
||||
worth a sentence on the card. A collapsed row keeps it in the title
|
||||
and shows it in full once the row expands. */}
|
||||
{!compact && audience.narrowedNote ? (
|
||||
<span className="block" data-testid="interaction-audience-note">
|
||||
{audience.narrowedNote}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act as reactAct } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ISSUE_THREAD_INTERACTION_KINDS as INTERACTION_KINDS,
|
||||
type InteractionResolverGovernance,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
GOVERNANCE_UNSET,
|
||||
InteractionGovernancePanel,
|
||||
applyGovernanceChange,
|
||||
toGovernanceSelectValue,
|
||||
} from "./InteractionGovernancePanel";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
if (typeof reactAct === "function") {
|
||||
await reactAct(callback);
|
||||
return;
|
||||
}
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
function renderPanel(governance: InteractionResolverGovernance = {}, onChange = vi.fn()) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<InteractionGovernancePanel governance={governance} onChange={onChange} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
return { host: container, onChange };
|
||||
}
|
||||
|
||||
/** Exact visible text of a closed select trigger (the chevron contributes none). */
|
||||
function triggerText(host: HTMLElement, testId: string): string {
|
||||
const trigger = host.querySelector(`[data-testid="${testId}"]`);
|
||||
expect(trigger).toBeTruthy();
|
||||
return (trigger?.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe("InteractionGovernancePanel", () => {
|
||||
it("presents Anyone as the visible default for a company with no overrides", () => {
|
||||
const { host } = renderPanel();
|
||||
|
||||
const defaultSelect = host.querySelector('[data-testid="governance-request_confirmation-default"]');
|
||||
expect(defaultSelect?.textContent).toContain("Anyone (default)");
|
||||
const cap = host.querySelector('[data-testid="governance-request_confirmation-cap"]');
|
||||
expect(cap?.textContent).toContain("No cap");
|
||||
});
|
||||
|
||||
it("describes the open default without presenting it as board-required", () => {
|
||||
const { host } = renderPanel();
|
||||
const copy = host.textContent ?? "";
|
||||
|
||||
expect(copy).toContain("Thread interactions are open by default");
|
||||
expect(copy).toContain("the board or any agent, including the one that asked");
|
||||
expect(copy).not.toContain("Board only");
|
||||
expect(copy).not.toContain("always require the board");
|
||||
// Governed tool approvals keep their human-only guarantee.
|
||||
expect(copy).toContain("Tool-approval confirmations always stay");
|
||||
expect(copy).toContain("Human only");
|
||||
});
|
||||
|
||||
it("shows a stored narrowing override on the matching row", () => {
|
||||
const { host } = renderPanel({
|
||||
request_confirmation: { defaultPolicy: "human_only", cap: "not_creator" },
|
||||
});
|
||||
|
||||
expect(
|
||||
host.querySelector('[data-testid="governance-request_confirmation-default"]')?.textContent,
|
||||
).toContain("Human only");
|
||||
expect(
|
||||
host.querySelector('[data-testid="governance-request_confirmation-cap"]')?.textContent,
|
||||
).toContain("Anyone except creator");
|
||||
// Untouched kinds still read as open.
|
||||
expect(
|
||||
host.querySelector('[data-testid="governance-suggest_tasks-default"]')?.textContent,
|
||||
).toContain("Anyone (default)");
|
||||
});
|
||||
|
||||
/**
|
||||
* PAP-17297: an empty `<SelectValue />` lets Radix portal the selected
|
||||
* option's whole subtree — label *and* effect sentence — into the closed
|
||||
* trigger, where `line-clamp-1` clipped it. `toContain` assertions cannot see
|
||||
* that, so these check the trigger's exact text.
|
||||
*/
|
||||
it("shows only the complete selected label in a closed trigger", () => {
|
||||
const { host } = renderPanel();
|
||||
|
||||
for (const kind of INTERACTION_KINDS) {
|
||||
expect(triggerText(host, `governance-${kind}-default`)).toBe("Anyone (default)");
|
||||
expect(triggerText(host, `governance-${kind}-cap`)).toBe("No cap");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a narrowed trigger free of the option effect sentence", () => {
|
||||
const { host } = renderPanel({
|
||||
request_confirmation: { defaultPolicy: "human_only", cap: "not_creator" },
|
||||
});
|
||||
|
||||
expect(triggerText(host, "governance-request_confirmation-default")).toBe("Human only");
|
||||
expect(triggerText(host, "governance-request_confirmation-cap")).toBe("Anyone except creator");
|
||||
// The effect prose belongs to the option list, never the trigger.
|
||||
expect(host.textContent).not.toContain("New cards wait for a person on the board");
|
||||
expect(host.textContent).not.toContain("narrowed to exclude its creator");
|
||||
expect(host.textContent).not.toContain("New cards are open");
|
||||
});
|
||||
|
||||
it("still offers the effect sentence inside the open option list", async () => {
|
||||
const { host } = renderPanel();
|
||||
const trigger = host.querySelector<HTMLElement>(
|
||||
'[data-testid="governance-request_confirmation-default"]',
|
||||
);
|
||||
expect(trigger).toBeTruthy();
|
||||
|
||||
await act(() => {
|
||||
trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
});
|
||||
|
||||
const listbox = document.querySelector('[data-slot="select-content"]');
|
||||
const listText = listbox?.textContent ?? "";
|
||||
expect(listText).toContain("Anyone (default)");
|
||||
expect(listText).toContain(
|
||||
"New cards are open — the board or any agent can respond, including the one that asked.",
|
||||
);
|
||||
expect(listText).toContain("New cards wait for a person on the board. Agents are turned away.");
|
||||
// Each option keeps a label-only typeahead value so keyboard search does not
|
||||
// match the effect prose.
|
||||
expect(
|
||||
Array.from(listbox?.querySelectorAll('[data-slot="select-item"]') ?? []).length,
|
||||
).toBe(3);
|
||||
// Reflow guard: the popover is capped so long prose wraps instead of
|
||||
// stretching past a narrow viewport.
|
||||
expect(listbox?.className).toContain("max-w-(--sz-280px)");
|
||||
});
|
||||
|
||||
it("renders a select for every interaction kind", () => {
|
||||
const { host } = renderPanel();
|
||||
expect(host.querySelectorAll('[data-testid$="-default"]')).toHaveLength(5);
|
||||
expect(host.querySelectorAll('[data-testid$="-cap"]')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("surfaces a save failure", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<InteractionGovernancePanel
|
||||
governance={{}}
|
||||
onChange={vi.fn()}
|
||||
errorMessage="Failed to save interaction governance"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain("Failed to save interaction governance");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toGovernanceSelectValue", () => {
|
||||
it("treats an absent override as the open default", () => {
|
||||
expect(toGovernanceSelectValue(undefined)).toBe(GOVERNANCE_UNSET);
|
||||
});
|
||||
|
||||
it("treats a stored open audience — canonical or legacy alias — as the default", () => {
|
||||
expect(toGovernanceSelectValue("anyone")).toBe(GOVERNANCE_UNSET);
|
||||
expect(toGovernanceSelectValue("board_or_agents")).toBe(GOVERNANCE_UNSET);
|
||||
});
|
||||
|
||||
it("canonicalizes stored narrowing values, including the legacy board_only alias", () => {
|
||||
expect(toGovernanceSelectValue("not_creator")).toBe("not_creator");
|
||||
expect(toGovernanceSelectValue("human_only")).toBe("human_only");
|
||||
expect(toGovernanceSelectValue("board_only")).toBe("human_only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyGovernanceChange", () => {
|
||||
it("stores a narrowing override", () => {
|
||||
expect(applyGovernanceChange({}, "request_confirmation", "defaultPolicy", "human_only")).toEqual({
|
||||
request_confirmation: { defaultPolicy: "human_only" },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the override when the open default is re-selected", () => {
|
||||
const current: InteractionResolverGovernance = {
|
||||
request_confirmation: { defaultPolicy: "human_only" },
|
||||
};
|
||||
expect(
|
||||
applyGovernanceChange(current, "request_confirmation", "defaultPolicy", GOVERNANCE_UNSET),
|
||||
).toEqual({});
|
||||
// Immutable: the caller's map is untouched.
|
||||
expect(current.request_confirmation?.defaultPolicy).toBe("human_only");
|
||||
});
|
||||
|
||||
it("keeps a sibling field when only one is cleared", () => {
|
||||
const current: InteractionResolverGovernance = {
|
||||
request_confirmation: { defaultPolicy: "human_only", cap: "not_creator" },
|
||||
};
|
||||
expect(applyGovernanceChange(current, "request_confirmation", "cap", GOVERNANCE_UNSET)).toEqual({
|
||||
request_confirmation: { defaultPolicy: "human_only" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
import { Fragment } from "react";
|
||||
import {
|
||||
ISSUE_THREAD_INTERACTION_KINDS,
|
||||
normalizeIssueThreadInteractionResolverPolicy,
|
||||
type InteractionResolverGovernance,
|
||||
type IssueThreadInteractionCanonicalResolverPolicy,
|
||||
type IssueThreadInteractionKind,
|
||||
type IssueThreadInteractionResolverPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { resolverPolicyLabel } from "../lib/interaction-audience";
|
||||
|
||||
const INTERACTION_KIND_LABELS: Record<IssueThreadInteractionKind, string> = {
|
||||
suggest_tasks: "Suggested tasks",
|
||||
ask_user_questions: "Ask user questions",
|
||||
request_confirmation: "Confirmations",
|
||||
request_checkbox_confirmation: "Checkbox confirmations",
|
||||
request_item_verdicts: "Item verdicts",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sentinel for "no override" — Radix Select disallows empty-string item values.
|
||||
* Under the open-default contract (PAP-17280) an absent override *is* the open
|
||||
* audience, so this sentinel is what a company sees selected until it
|
||||
* deliberately narrows a kind.
|
||||
*/
|
||||
export const GOVERNANCE_UNSET = "default";
|
||||
export type GovernanceSelectValue = typeof GOVERNANCE_UNSET | IssueThreadInteractionResolverPolicy;
|
||||
|
||||
export type GovernanceField = "defaultPolicy" | "cap";
|
||||
|
||||
/**
|
||||
* Only *narrowing* policies are offered. `anyone` is the product default, so
|
||||
* requesting it as a default override is a no-op, and capping at `anyone` cannot
|
||||
* narrow anything — both collapse into the unset sentinel, which is presented as
|
||||
* the visible default.
|
||||
*/
|
||||
const NARROWING_POLICIES: readonly IssueThreadInteractionCanonicalResolverPolicy[] = [
|
||||
"not_creator",
|
||||
"human_only",
|
||||
];
|
||||
|
||||
const UNSET_LABELS: Record<GovernanceField, string> = {
|
||||
defaultPolicy: "Anyone (default)",
|
||||
cap: "No cap",
|
||||
};
|
||||
|
||||
const UNSET_EFFECTS: Record<GovernanceField, string> = {
|
||||
defaultPolicy: "New cards are open — the board or any agent can respond, including the one that asked.",
|
||||
cap: "A request keeps whatever audience it asks for.",
|
||||
};
|
||||
|
||||
const DEFAULT_POLICY_EFFECTS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
|
||||
anyone: UNSET_EFFECTS.defaultPolicy,
|
||||
not_creator: "New cards exclude the agent that created them, so the answer comes from someone else.",
|
||||
human_only: "New cards wait for a person on the board. Agents are turned away.",
|
||||
};
|
||||
|
||||
const CAP_EFFECTS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
|
||||
anyone: UNSET_EFFECTS.cap,
|
||||
not_creator: "Even a card that asks for Anyone is narrowed to exclude its creator.",
|
||||
human_only: "Every card of this kind waits for a person, whatever it asked for.",
|
||||
};
|
||||
|
||||
function governanceOptions(field: GovernanceField): {
|
||||
value: GovernanceSelectValue;
|
||||
label: string;
|
||||
effect: string;
|
||||
}[] {
|
||||
const effects = field === "cap" ? CAP_EFFECTS : DEFAULT_POLICY_EFFECTS;
|
||||
return [
|
||||
{ value: GOVERNANCE_UNSET, label: UNSET_LABELS[field], effect: UNSET_EFFECTS[field] },
|
||||
...NARROWING_POLICIES.map((policy) => ({
|
||||
value: policy as GovernanceSelectValue,
|
||||
label: resolverPolicyLabel(policy),
|
||||
effect: effects[policy],
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The label a *closed* trigger must show. Derived from the value rather than
|
||||
* looked up in the option list so an out-of-list value (a raw `anyone`, say)
|
||||
* still renders a complete, truthful label instead of falling back to a lie.
|
||||
*/
|
||||
export function governanceValueLabel(field: GovernanceField, value: GovernanceSelectValue): string {
|
||||
return value === GOVERNANCE_UNSET ? UNSET_LABELS[field] : resolverPolicyLabel(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a persisted override onto a select value. A stored `anyone` — including
|
||||
* the deprecated `board_or_agents` alias — is the open default, so it shows as
|
||||
* the unset sentinel rather than as a narrowing override.
|
||||
*/
|
||||
export function toGovernanceSelectValue(
|
||||
policy: IssueThreadInteractionResolverPolicy | undefined,
|
||||
): GovernanceSelectValue {
|
||||
if (!policy) return GOVERNANCE_UNSET;
|
||||
const canonical = normalizeIssueThreadInteractionResolverPolicy(policy);
|
||||
return canonical === "anyone" ? GOVERNANCE_UNSET : canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single (kind, field) change to a governance map immutably, pruning
|
||||
* empty entries so the persisted object stays sparse (only real overrides).
|
||||
*/
|
||||
export function applyGovernanceChange(
|
||||
current: InteractionResolverGovernance,
|
||||
kind: IssueThreadInteractionKind,
|
||||
field: GovernanceField,
|
||||
value: GovernanceSelectValue,
|
||||
): InteractionResolverGovernance {
|
||||
const next: InteractionResolverGovernance = { ...current };
|
||||
const entry = { ...(next[kind] ?? {}) };
|
||||
if (value === GOVERNANCE_UNSET) {
|
||||
delete entry[field];
|
||||
} else {
|
||||
entry[field] = value;
|
||||
}
|
||||
if (entry.defaultPolicy === undefined && entry.cap === undefined) {
|
||||
delete next[kind];
|
||||
} else {
|
||||
next[kind] = entry;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function GovernanceSelect({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
testId,
|
||||
ariaLabel,
|
||||
mobileLabel,
|
||||
}: {
|
||||
field: GovernanceField;
|
||||
value: GovernanceSelectValue;
|
||||
onChange: (value: GovernanceSelectValue) => void;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
ariaLabel: string;
|
||||
mobileLabel: string;
|
||||
}) {
|
||||
const options = governanceOptions(field);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{/*
|
||||
* Below `sm` the governance grid collapses to a single column (see the
|
||||
* grid classes on the panel), detaching each select from its column
|
||||
* header. Surface a mobile-only inline label so the control stays
|
||||
* self-describing for sighted users, and always carry `aria-label` for
|
||||
* screen-reader pairing. WCAG 2.1 SC 1.4.10 (Reflow) — design review R2.
|
||||
*/}
|
||||
<span className="mb-1 block text-xs font-medium text-muted-foreground uppercase tracking-wide sm:hidden">
|
||||
{mobileLabel}
|
||||
</span>
|
||||
<Select value={value} onValueChange={(v) => onChange(v as GovernanceSelectValue)} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
aria-label={ariaLabel}
|
||||
// 208px is sized for the longest label the control can hold —
|
||||
// `Anyone except creator` needs ~150px of text room, and 170px only
|
||||
// left 122px after padding, gap and chevron, so it clipped even once
|
||||
// the effect sentence was gone (PAP-17297).
|
||||
className="w-full min-w-0 text-xs sm:w-(--sz-208px)"
|
||||
data-testid={testId}
|
||||
>
|
||||
{/*
|
||||
* Explicit children, not the default `<SelectValue />`. Radix portals
|
||||
* the selected item's *whole* subtree into an empty value node, which
|
||||
* dragged each option's effect sentence into the closed trigger and
|
||||
* clipped the selected label (desktop truncated the prose, mobile cut
|
||||
* `Anyone (default)` mid-label — PAP-17293/PAP-17297). Passing children
|
||||
* sets `valueNodeHasChildren`, which suppresses that portal, so the
|
||||
* trigger shows exactly the label and nothing else.
|
||||
*/}
|
||||
<SelectValue>{governanceValueLabel(field, value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
{/*
|
||||
* Cap the option list so the effect sentences wrap instead of stretching
|
||||
* the popover past a ~390px viewport (WCAG 2.1 SC 1.4.10 Reflow).
|
||||
*/}
|
||||
<SelectContent className="max-w-(--sz-280px) sm:max-w-(--sz-360px)">
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
// Keyboard typeahead matches on `textValue` when given; without it
|
||||
// Radix would match against the effect prose too.
|
||||
textValue={option.label}
|
||||
className="text-xs"
|
||||
>
|
||||
{/*
|
||||
* Effect preview lives inside the option so the consequence of a
|
||||
* narrowing choice is legible at the moment of choosing, not only
|
||||
* after saving (PAP-17280).
|
||||
*/}
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span>{option.label}</span>
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||||
{option.effect}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Company-level interaction governance: the per-kind default audience and cap.
|
||||
*
|
||||
* The open default is the headline — interactions are resolvable by anyone in
|
||||
* the company unless a row here narrows them — so the panel only offers
|
||||
* narrowing choices and never presents an unrestricted card as board-required
|
||||
* (PAP-17280, contract in `doc/SPEC-implementation.md` §9.8.1).
|
||||
*/
|
||||
export function InteractionGovernancePanel({
|
||||
governance,
|
||||
onChange,
|
||||
isPending,
|
||||
errorMessage,
|
||||
}: {
|
||||
governance: InteractionResolverGovernance;
|
||||
onChange: (kind: IssueThreadInteractionKind, field: GovernanceField, value: GovernanceSelectValue) => void;
|
||||
isPending?: boolean;
|
||||
errorMessage?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4" data-testid="company-settings-interaction-governance-section">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Interaction governance
|
||||
</div>
|
||||
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thread interactions are open by default:{" "}
|
||||
<span className="font-medium text-foreground">Anyone</span> in the company — the
|
||||
board or any agent, including the one that asked — can respond. Narrow a kind
|
||||
only when you need to.{" "}
|
||||
<span className="font-medium text-foreground">Default policy</span> is the
|
||||
audience new cards get when the requester does not ask for one;{" "}
|
||||
<span className="font-medium text-foreground">Cap</span> narrows every request of
|
||||
that kind and can never widen one. Tool-approval confirmations always stay{" "}
|
||||
<span className="font-medium text-foreground">Human only</span>.
|
||||
</p>
|
||||
{/*
|
||||
* Responsive: below `sm` the row collapses to a single column so the
|
||||
* two 170px selects never force horizontal overflow on a ~390px
|
||||
* viewport (WCAG 2.1 SC 1.4.10 Reflow — design review R2). Each kind
|
||||
* then stacks as: label → Default policy → Cap, each full-width with
|
||||
* its own inline label. At `sm`+ it restores the aligned 3-col grid.
|
||||
*/}
|
||||
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-[1fr_auto_auto] sm:items-center sm:gap-x-4 sm:gap-y-2.5">
|
||||
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
||||
Kind
|
||||
</div>
|
||||
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
||||
Default policy
|
||||
</div>
|
||||
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
||||
Cap
|
||||
</div>
|
||||
{ISSUE_THREAD_INTERACTION_KINDS.map((kind) => {
|
||||
const entry = governance[kind] ?? {};
|
||||
const kindLabel = INTERACTION_KIND_LABELS[kind];
|
||||
return (
|
||||
<Fragment key={kind}>
|
||||
<div className="text-sm font-medium sm:font-normal">{kindLabel}</div>
|
||||
<GovernanceSelect
|
||||
field="defaultPolicy"
|
||||
testId={`governance-${kind}-default`}
|
||||
ariaLabel={`Default resolver audience for ${kindLabel}`}
|
||||
mobileLabel="Default policy"
|
||||
value={toGovernanceSelectValue(entry.defaultPolicy)}
|
||||
disabled={isPending}
|
||||
onChange={(v) => onChange(kind, "defaultPolicy", v)}
|
||||
/>
|
||||
<GovernanceSelect
|
||||
field="cap"
|
||||
testId={`governance-${kind}-cap`}
|
||||
ariaLabel={`Resolver cap for ${kindLabel}`}
|
||||
mobileLabel="Cap"
|
||||
value={toGovernanceSelectValue(entry.cap)}
|
||||
disabled={isPending}
|
||||
onChange={(v) => onChange(kind, "cap", v)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<span className="text-xs text-destructive">{errorMessage}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -211,9 +211,13 @@ function createSuggestedTasksInteraction(
|
|||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
resolverPolicy: overrides.resolverPolicy ?? "anyone",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
|
||||
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
|
||||
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
|
||||
?? { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -253,9 +257,13 @@ function createQuestionInteraction(
|
|||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
resolverPolicy: overrides.resolverPolicy ?? "anyone",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
|
||||
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
|
||||
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
|
||||
?? { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -289,9 +297,13 @@ function createExpiredRequestConfirmationInteraction(
|
|||
commentId: "comment-1",
|
||||
},
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
resolverPolicy: overrides.resolverPolicy ?? "anyone",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
|
||||
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
|
||||
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
|
||||
?? { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { act as reactAct, type ComponentProps, type ReactNode } from "react";
|
|||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { ApiError } from "../api/client";
|
||||
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
|
|
@ -33,6 +35,10 @@ import {
|
|||
agentResolvedRequestConfirmationInteraction,
|
||||
withdrawnRequestConfirmationInteraction,
|
||||
issueClosedRequestConfirmationInteraction,
|
||||
notCreatorRequestConfirmationInteraction,
|
||||
humanOnlyRequestConfirmationInteraction,
|
||||
companyCappedRequestConfirmationInteraction,
|
||||
legacyRestrictedRequestConfirmationInteraction,
|
||||
} from "../fixtures/issueThreadInteractionFixtures";
|
||||
|
||||
let root: Root | null = null;
|
||||
|
|
@ -507,6 +513,114 @@ describe("IssueThreadInteractionCard", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// PAP-17287: a denial is persistent, so the inline error keeps the server's
|
||||
// reason and names who can respond instead of offering a doomed retry.
|
||||
it("keeps the server denial reason in an aria-live region when a confirmation is refused", async () => {
|
||||
const onAcceptInteraction = vi.fn(async () => {
|
||||
throw new ApiError("This issue-thread interaction is human-only", 403, {
|
||||
error: "This issue-thread interaction is human-only",
|
||||
code: "interaction_human_only",
|
||||
});
|
||||
});
|
||||
const host = renderCard({
|
||||
interaction: humanOnlyRequestConfirmationInteraction,
|
||||
onAcceptInteraction,
|
||||
});
|
||||
|
||||
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Approve"),
|
||||
);
|
||||
await act(async () => {
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const error = host.querySelector('[data-testid="interaction-action-error"]');
|
||||
expect(error?.getAttribute("aria-live")).toBe("assertive");
|
||||
expect(error?.textContent).toContain("This issue-thread interaction is human-only.");
|
||||
expect(error?.textContent).toContain("Only the board can respond.");
|
||||
expect(error?.textContent).not.toMatch(/try again/i);
|
||||
// PAP-17289: one live region, not two. `role="alert"` is itself an
|
||||
// assertive live region, so nesting it inside this wrapper can announce the
|
||||
// same denial twice.
|
||||
expect(error?.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(host.querySelectorAll('[aria-live], [role="alert"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it("still offers a retry when a resolution fails for a transient reason", async () => {
|
||||
const onAcceptInteraction = vi.fn(async () => {
|
||||
throw new ApiError("Request failed: 503", 503, null);
|
||||
});
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestConfirmationInteraction,
|
||||
onAcceptInteraction,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
Array.from(host.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Approve plan"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(
|
||||
host.querySelector('[data-testid="interaction-action-error"]')?.textContent,
|
||||
).toBe("Request failed: 503. Try again.");
|
||||
});
|
||||
|
||||
it("surfaces a denied suggested-task acceptance instead of failing silently", async () => {
|
||||
const onAcceptInteraction = vi.fn(async () => {
|
||||
throw new ApiError("Only the addressed agent or an authorized human may resolve this issue-thread interaction", 403, {
|
||||
error: "Only the addressed agent or an authorized human may resolve this issue-thread interaction",
|
||||
code: "interaction_addressee_mismatch",
|
||||
});
|
||||
});
|
||||
const host = renderCard({
|
||||
interaction: pendingSuggestedTasksInteraction,
|
||||
onAcceptInteraction,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
Array.from(host.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Accept"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const error = host.querySelector('[data-testid="interaction-action-error"]');
|
||||
expect(error?.getAttribute("aria-live")).toBe("assertive");
|
||||
expect(error?.textContent).toContain("may resolve this issue-thread interaction.");
|
||||
});
|
||||
|
||||
it("surfaces a denied answer submission on a questions card", async () => {
|
||||
const onSubmitInteractionAnswers = vi.fn(async () => {
|
||||
throw new ApiError("This issue-thread interaction is human-only", 403, {
|
||||
error: "This issue-thread interaction is human-only",
|
||||
code: "interaction_human_only",
|
||||
});
|
||||
});
|
||||
const host = renderCard({
|
||||
interaction: pendingAskUserQuestionsInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
});
|
||||
|
||||
// Answer every question so Submit is enabled, then submit.
|
||||
for (const group of ['[role="radio"]', '[role="checkbox"]']) {
|
||||
const option = host.querySelector(group);
|
||||
await act(async () => {
|
||||
(option as HTMLElement | null)?.click();
|
||||
});
|
||||
}
|
||||
const submit = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Send answers"),
|
||||
);
|
||||
expect(submit?.hasAttribute("disabled")).toBe(false);
|
||||
await act(async () => {
|
||||
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(
|
||||
host.querySelector('[data-testid="interaction-action-error"]')?.textContent,
|
||||
).toContain("This issue-thread interaction is human-only.");
|
||||
});
|
||||
|
||||
it("standardizes the bare-reject button to Reject even when the payload carries a legacy rejectLabel", () => {
|
||||
const host = renderCard({
|
||||
interaction: {
|
||||
|
|
@ -1002,4 +1116,92 @@ describe("IssueThreadInteractionCard tool-action card", () => {
|
|||
const occurrences = (host.textContent ?? "").split(label).length - 1;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* The effective audience is shown *before* anyone responds, so a reader never
|
||||
* has to guess whether an open card is waiting on them (PAP-17280).
|
||||
*/
|
||||
describe("IssueThreadInteractionCard resolver audience", () => {
|
||||
it("shows an open audience on a pending card created without a restriction", () => {
|
||||
const host = renderCard({ interaction: pendingRequestConfirmationInteraction });
|
||||
|
||||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||||
expect(audience?.getAttribute("data-audience-policy")).toBe("anyone");
|
||||
expect(audience?.getAttribute("data-audience-open")).toBe("true");
|
||||
expect(audience?.textContent).toContain("Anyone");
|
||||
expect(audience?.textContent).toContain("the board or any agent, including the one that asked");
|
||||
// An open card must never read as board-required.
|
||||
expect(audience?.textContent).not.toMatch(/only a person on the board/i);
|
||||
expect(host.querySelector('[data-testid="interaction-audience-note"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("names the excluded creator for an explicit not_creator card", () => {
|
||||
const host = renderCard({
|
||||
interaction: notCreatorRequestConfirmationInteraction,
|
||||
agentMap: new Map([["agent-codex", { name: "CodexCoder" } as Agent]]),
|
||||
});
|
||||
|
||||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||||
expect(audience?.getAttribute("data-audience-policy")).toBe("not_creator");
|
||||
expect(audience?.getAttribute("data-audience-open")).toBe("false");
|
||||
expect(audience?.textContent).toContain("Anyone except creator");
|
||||
expect(audience?.textContent).toContain("except CodexCoder can respond");
|
||||
});
|
||||
|
||||
it("keeps human-only ownership copy on a human-only card", () => {
|
||||
const host = renderCard({ interaction: humanOnlyRequestConfirmationInteraction });
|
||||
|
||||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||||
expect(audience?.getAttribute("data-audience-policy")).toBe("human_only");
|
||||
expect(audience?.textContent).toContain("Human only");
|
||||
expect(audience?.textContent).toContain("Only a person on the board can respond");
|
||||
});
|
||||
|
||||
it("keeps addressee ownership copy on an agent-addressed card", () => {
|
||||
const host = renderCard({
|
||||
interaction: agentAddressedRequestConfirmationInteraction,
|
||||
agentMap: new Map([["agent-codex", { name: "CodexCoder" } as Agent]]),
|
||||
});
|
||||
|
||||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||||
expect(audience?.getAttribute("data-audience-open")).toBe("false");
|
||||
expect(audience?.textContent).toContain("Addressed");
|
||||
expect(audience?.textContent).toContain("Only CodexCoder or a person on the board can respond");
|
||||
expect(audience?.textContent).not.toContain("Anyone");
|
||||
});
|
||||
|
||||
it("explains a company cap that narrowed the requested audience", () => {
|
||||
const host = renderCard({ interaction: companyCappedRequestConfirmationInteraction });
|
||||
|
||||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||||
expect(audience?.getAttribute("data-audience-policy")).toBe("human_only");
|
||||
expect(
|
||||
host.querySelector('[data-testid="interaction-audience-note"]')?.textContent,
|
||||
).toBe("Company interaction governance narrowed this from Anyone to Human only.");
|
||||
});
|
||||
|
||||
it("explains a legacy card that predates the open default", () => {
|
||||
const host = renderCard({ interaction: legacyRestrictedRequestConfirmationInteraction });
|
||||
|
||||
expect(
|
||||
host.querySelector('[data-testid="interaction-audience-note"]')?.textContent,
|
||||
).toContain("Created before Anyone became the default");
|
||||
});
|
||||
|
||||
it("omits the audience row once a card is resolved and shows who resolved it", () => {
|
||||
const host = renderCard({
|
||||
interaction: agentResolvedRequestConfirmationInteraction,
|
||||
agentMap: new Map([["agent-codex", { name: "CodexCoder" } as Agent]]),
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="interaction-audience"]')).toBeNull();
|
||||
const footer = host.querySelector('[data-testid="interaction-resolved-footer"]');
|
||||
expect(footer?.textContent).toContain("Resolved by");
|
||||
expect(footer?.textContent).toContain("CodexCoder");
|
||||
expect(
|
||||
footer?.querySelector('[data-testid="interaction-resolved-by-agent-chip"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Wrench, X, XCircle } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { describeInteractionAudience, type InteractionAudienceDescription } from "../lib/interaction-audience";
|
||||
import { interactionResolutionErrorMessage } from "../lib/interaction-resolution-error";
|
||||
import {
|
||||
buildSuggestedTaskTree,
|
||||
collectSuggestedTaskClientKeys,
|
||||
|
|
@ -28,6 +30,7 @@ import {
|
|||
type SuggestedTaskTreeNode,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||
import { InteractionAudienceLine } from "./InteractionAudienceLine";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { Button } from "./ui/button";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
|
@ -40,6 +43,48 @@ import { Badge } from "@/components/ui/badge";
|
|||
|
||||
const OTHER_ANSWER_ID = "__paperclip_other__";
|
||||
|
||||
/**
|
||||
* The card's server-evaluated audience, shared with the per-kind subcards below
|
||||
* (PAP-17287). A subcard that catches a rejected resolution needs to name who
|
||||
* *can* respond, and re-deriving the audience per subcard would let two parts of
|
||||
* the same card describe one policy differently.
|
||||
*/
|
||||
const InteractionAudienceContext = createContext<InteractionAudienceDescription | null>(null);
|
||||
|
||||
/**
|
||||
* Turns a rejected resolution into copy for the inline error region: the
|
||||
* server's own denial reason, plus who can respond when the denial is an
|
||||
* audience refusal. Never invites a retry that policy will refuse again.
|
||||
*/
|
||||
function useResolutionErrorMessage() {
|
||||
const audience = useContext(InteractionAudienceContext);
|
||||
return (error: unknown) => interactionResolutionErrorMessage(error, audience);
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline resolution error. Announced through an `aria-live` region because a
|
||||
* denial is the only feedback a failed decision gets — the row stays put and no
|
||||
* toast fires on the attention surface.
|
||||
*
|
||||
* The live region is the *outer* wrapper, mounted whether or not there is a
|
||||
* message: a region has to be in the accessibility tree before its content
|
||||
* changes for the change to be announced. The styled inner div deliberately
|
||||
* carries no `role="alert"` — `alert` is itself an assertive live region, and
|
||||
* nesting one inside another makes some screen reader / browser pairs announce
|
||||
* the same denial twice (PAP-17289).
|
||||
*/
|
||||
function InteractionActionError({ message }: { message: string | null }) {
|
||||
return (
|
||||
<div aria-live="assertive" data-testid="interaction-action-error">
|
||||
{message ? (
|
||||
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueThreadInteractionCardProps {
|
||||
interaction: IssueThreadInteraction;
|
||||
agentMap?: Map<string, Agent>;
|
||||
|
|
@ -674,14 +719,19 @@ function SuggestTasksCard({
|
|||
[interaction.payload.tasks],
|
||||
);
|
||||
const selectedCount = selectedClientKeys.size;
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const resolutionErrorMessage = useResolutionErrorMessage();
|
||||
const createdCount = interaction.result?.createdTasks?.length ?? 0;
|
||||
const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0;
|
||||
|
||||
async function handleAccept() {
|
||||
if (!onAcceptInteraction) return;
|
||||
setWorking("accept");
|
||||
setActionError(null);
|
||||
try {
|
||||
await onAcceptInteraction(interaction, [...selectedClientKeys]);
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
|
|
@ -690,9 +740,12 @@ function SuggestTasksCard({
|
|||
async function handleReject() {
|
||||
if (!onRejectInteraction) return;
|
||||
setWorking("reject");
|
||||
setActionError(null);
|
||||
try {
|
||||
await onRejectInteraction(interaction, rejectReason.trim() || undefined);
|
||||
setRejecting(false);
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
|
|
@ -854,6 +907,8 @@ function SuggestTasksCard({
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<InteractionActionError message={actionError} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -953,6 +1008,8 @@ function AskUserQuestionsCard({
|
|||
);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const resolutionErrorMessage = useResolutionErrorMessage();
|
||||
|
||||
useEffect(() => {
|
||||
setDraftAnswers(
|
||||
|
|
@ -1028,6 +1085,7 @@ function AskUserQuestionsCard({
|
|||
async function handleSubmit() {
|
||||
if (!onSubmitInteractionAnswers || !canSubmit) return;
|
||||
setWorking(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await onSubmitInteractionAnswers(
|
||||
interaction,
|
||||
|
|
@ -1042,6 +1100,8 @@ function AskUserQuestionsCard({
|
|||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
|
|
@ -1050,8 +1110,11 @@ function AskUserQuestionsCard({
|
|||
async function handleCancel() {
|
||||
if (!onCancelInteraction) return;
|
||||
setCancelling(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await onCancelInteraction(interaction);
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
|
|
@ -1227,6 +1290,8 @@ function AskUserQuestionsCard({
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InteractionActionError message={actionError} />
|
||||
</div>
|
||||
) : interaction.status === "cancelled" ? (
|
||||
<div className="rounded-2xl border border-rose-300/60 bg-rose-50/85 p-4 text-sm leading-6 text-rose-950 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
|
|
@ -1766,6 +1831,7 @@ function RequestToolActionCard({
|
|||
const [rejectReason, setRejectReason] = useState("");
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const resolutionErrorMessage = useResolutionErrorMessage();
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const isPending = state === "pending";
|
||||
const isDestructive = payload.risk === "destructive";
|
||||
|
|
@ -1789,8 +1855,8 @@ function RequestToolActionCard({
|
|||
setActionError(null);
|
||||
try {
|
||||
await onAcceptInteraction(interaction);
|
||||
} catch {
|
||||
setActionError("Couldn't submit. Try again.");
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
|
|
@ -1803,8 +1869,8 @@ function RequestToolActionCard({
|
|||
try {
|
||||
await onRejectInteraction(interaction, rejectReason.trim() || undefined);
|
||||
setRejecting(false);
|
||||
} catch {
|
||||
setActionError("Couldn't submit. Try again.");
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
|
|
@ -1904,11 +1970,7 @@ function RequestToolActionCard({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
<InteractionActionError message={actionError} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -2112,11 +2174,7 @@ function ConfirmationActionRow({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
<InteractionActionError message={actionError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2145,6 +2203,7 @@ function RequestConfirmationCard({
|
|||
}) {
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const resolutionErrorMessage = useResolutionErrorMessage();
|
||||
const [shots, setShots] = useState<{ name: string; url: string }[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
|
@ -2201,8 +2260,8 @@ function RequestConfirmationCard({
|
|||
setActionError(null);
|
||||
try {
|
||||
await onAcceptInteraction(interaction);
|
||||
} catch {
|
||||
setActionError("Try again");
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
|
|
@ -2214,8 +2273,8 @@ function RequestConfirmationCard({
|
|||
setActionError(null);
|
||||
try {
|
||||
await onRejectInteraction(interaction, reason);
|
||||
} catch {
|
||||
setActionError("Try again");
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
|
|
@ -2489,6 +2548,7 @@ function RequestCheckboxConfirmationCard({
|
|||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [acceptAttempted, setAcceptAttempted] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const resolutionErrorMessage = useResolutionErrorMessage();
|
||||
|
||||
const optionSeed = useMemo(() => optionIds.join("\n"), [optionIds]);
|
||||
|
||||
|
|
@ -2551,8 +2611,8 @@ function RequestCheckboxConfirmationCard({
|
|||
setActionError(null);
|
||||
try {
|
||||
await onAcceptInteraction(interaction, undefined, [...selectedOptionIds]);
|
||||
} catch {
|
||||
setActionError("Try again");
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
|
|
@ -2564,8 +2624,8 @@ function RequestCheckboxConfirmationCard({
|
|||
setActionError(null);
|
||||
try {
|
||||
await onRejectInteraction(interaction, reason);
|
||||
} catch {
|
||||
setActionError("Try again");
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
|
|
@ -2832,6 +2892,7 @@ function RequestItemVerdictsCard({
|
|||
const [working, setWorking] = useState(false);
|
||||
const [attempted, setAttempted] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const resolutionErrorMessage = useResolutionErrorMessage();
|
||||
|
||||
// When the server merges newly-resolved items, drop their local drafts and
|
||||
// clear the applying/working state so the terminal chips take over (S3 → S4).
|
||||
|
|
@ -2920,8 +2981,8 @@ function RequestItemVerdictsCard({
|
|||
await onSubmitInteractionVerdicts(interaction, verdicts);
|
||||
// Success: the parent refetch updates `interaction.result`, the effect
|
||||
// above clears drafts + applying state, and terminal chips render.
|
||||
} catch {
|
||||
setActionError("Try again");
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
setApplyingItemIds(new Set());
|
||||
setWorking(false);
|
||||
}
|
||||
|
|
@ -3115,11 +3176,7 @@ function RequestItemVerdictsCard({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
<InteractionActionError message={actionError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3252,6 +3309,15 @@ export function IssueThreadInteractionCard({
|
|||
userLabelMap,
|
||||
})
|
||||
: null;
|
||||
// PAP-17280: the effective audience, shown *before* anyone responds so a
|
||||
// reader never has to guess whether an open card is waiting on them. Derived
|
||||
// from the same server snapshot the resolver routes enforce, so the copy
|
||||
// cannot promise a wider audience than the API allows.
|
||||
const audience = describeInteractionAudience({
|
||||
interaction,
|
||||
creatorLabel: createdByLabel,
|
||||
addresseeLabel,
|
||||
});
|
||||
const statusText =
|
||||
adminOutcome === "withdrawn"
|
||||
? "Withdrawn"
|
||||
|
|
@ -3262,160 +3328,172 @@ export function IssueThreadInteractionCard({
|
|||
: statusLabel(interaction.status);
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border p-5 shadow-none", styles.shell)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 basis-64">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn("inline-flex items-center gap-1 rounded-sm border px-2.5 py-1 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow)", styles.badge)}>
|
||||
<StatusIcon className={cn("h-3.5 w-3.5", iconSpin && "animate-spin")} />
|
||||
{isPlan ? "Plan" : interactionKindLabel(interaction.kind)}
|
||||
<span className="text-current/60">/</span>
|
||||
{statusText}
|
||||
</span>
|
||||
{addresseeLabel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1"
|
||||
data-testid="interaction-addressee-badge"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
For {addresseeLabel}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs text-xs">
|
||||
Directed to {addresseeLabel}. Agent-addressed interactions are handled by that agent and are kept out of the board attention feed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
// Every nested subcard resolves the same interaction, so they all explain a
|
||||
// denial with the same audience the header states (PAP-17287).
|
||||
<InteractionAudienceContext.Provider value={audience}>
|
||||
<div className={cn("rounded-lg border p-5 shadow-none", styles.shell)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 basis-64">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn("inline-flex items-center gap-1 rounded-sm border px-2.5 py-1 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow)", styles.badge)}>
|
||||
<StatusIcon className={cn("h-3.5 w-3.5", iconSpin && "animate-spin")} />
|
||||
{isPlan ? "Plan" : interactionKindLabel(interaction.kind)}
|
||||
<span className="text-current/60">/</span>
|
||||
{statusText}
|
||||
</span>
|
||||
{addresseeLabel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1"
|
||||
data-testid="interaction-addressee-badge"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
For {addresseeLabel}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs text-xs">
|
||||
Directed to {addresseeLabel}. Agent-addressed interactions are owned by that agent and are kept out of the open company attention feed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-lg font-bold text-foreground">
|
||||
{interaction.title
|
||||
?? (interaction.kind === "suggest_tasks"
|
||||
? "Suggested task tree"
|
||||
: interaction.kind === "ask_user_questions"
|
||||
// Only a human-only card is genuinely "for the operator";
|
||||
// an open card is answerable by any teammate (PAP-17280).
|
||||
? interaction.payload.title
|
||||
?? (audience.policy === "human_only"
|
||||
? "Questions for the operator"
|
||||
: "Questions to answer")
|
||||
: interaction.kind === "request_checkbox_confirmation"
|
||||
? "Checkbox confirmation requested"
|
||||
: isToolAction
|
||||
? "Tool approval requested"
|
||||
: interaction.kind === "request_item_verdicts"
|
||||
? "Review these items"
|
||||
: isPlan
|
||||
? "Plan review"
|
||||
: "Confirmation requested")}
|
||||
</div>
|
||||
{interaction.summary ? (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
{interaction.summary}
|
||||
</p>
|
||||
) : null}
|
||||
{interaction.status === "pending" ? (
|
||||
<InteractionAudienceLine audience={audience} className="mt-3" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-lg font-bold text-foreground">
|
||||
{interaction.title
|
||||
?? (interaction.kind === "suggest_tasks"
|
||||
? "Suggested task tree"
|
||||
: interaction.kind === "ask_user_questions"
|
||||
? interaction.payload.title ?? "Questions for the operator"
|
||||
: interaction.kind === "request_checkbox_confirmation"
|
||||
? "Checkbox confirmation requested"
|
||||
: isToolAction
|
||||
? "Tool approval requested"
|
||||
: interaction.kind === "request_item_verdicts"
|
||||
? "Review these items"
|
||||
: isPlan
|
||||
? "Plan review"
|
||||
: "Confirmation requested")}
|
||||
</div>
|
||||
{interaction.summary ? (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
{interaction.summary}
|
||||
</p>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="rounded-sm border border-border/70 bg-transparent px-3 py-2 text-right text-xs text-muted-foreground">
|
||||
<div className="font-medium text-foreground">{formatShortDate(interaction.createdAt)}</div>
|
||||
<div>proposed by {createdByLabel}</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Created {formatDateTime(interaction.createdAt)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="rounded-sm border border-border/70 bg-transparent px-3 py-2 text-right text-xs text-muted-foreground">
|
||||
<div className="font-medium text-foreground">{formatShortDate(interaction.createdAt)}</div>
|
||||
<div>proposed by {createdByLabel}</div>
|
||||
<div className="mt-5">
|
||||
{interaction.kind === "suggest_tasks" ? (
|
||||
<SuggestTasksCard
|
||||
interaction={interaction}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
/>
|
||||
) : interaction.kind === "ask_user_questions" ? (
|
||||
<AskUserQuestionsCard
|
||||
interaction={interaction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : interaction.kind === "request_checkbox_confirmation" ? (
|
||||
<RequestCheckboxConfirmationCard
|
||||
interaction={interaction}
|
||||
primaryActionOnRight={primaryActionOnRight}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : isToolAction && interaction.kind === "request_confirmation" && toolActionState ? (
|
||||
<RequestToolActionCard
|
||||
interaction={interaction}
|
||||
state={toolActionState}
|
||||
resolvedByLabel={resolvedByLabel}
|
||||
requestedByLabel={createdByLabel}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : interaction.kind === "request_item_verdicts" ? (
|
||||
<RequestItemVerdictsCard
|
||||
interaction={interaction}
|
||||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
interaction={interaction}
|
||||
isPlan={isPlan}
|
||||
primaryActionOnRight={primaryActionOnRight}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onUploadImage={onUploadImage}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adminOutcome === "withdrawn" ? (
|
||||
<div
|
||||
className="mt-4 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-withdrawn-footer"
|
||||
>
|
||||
<div>
|
||||
Withdrawn by{" "}
|
||||
<span className="font-medium text-foreground">{resolvedByLabel ?? "an agent"}</span>
|
||||
{resolvedByAgent ? <ResolvedByAgentChip /> : null}
|
||||
{interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Created {formatDateTime(interaction.createdAt)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
{interaction.kind === "suggest_tasks" ? (
|
||||
<SuggestTasksCard
|
||||
interaction={interaction}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
/>
|
||||
) : interaction.kind === "ask_user_questions" ? (
|
||||
<AskUserQuestionsCard
|
||||
interaction={interaction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : interaction.kind === "request_checkbox_confirmation" ? (
|
||||
<RequestCheckboxConfirmationCard
|
||||
interaction={interaction}
|
||||
primaryActionOnRight={primaryActionOnRight}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : isToolAction && interaction.kind === "request_confirmation" && toolActionState ? (
|
||||
<RequestToolActionCard
|
||||
interaction={interaction}
|
||||
state={toolActionState}
|
||||
resolvedByLabel={resolvedByLabel}
|
||||
requestedByLabel={createdByLabel}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : interaction.kind === "request_item_verdicts" ? (
|
||||
<RequestItemVerdictsCard
|
||||
interaction={interaction}
|
||||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
interaction={interaction}
|
||||
isPlan={isPlan}
|
||||
primaryActionOnRight={primaryActionOnRight}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onUploadImage={onUploadImage}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adminOutcome === "withdrawn" ? (
|
||||
<div
|
||||
className="mt-4 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-withdrawn-footer"
|
||||
>
|
||||
<div>
|
||||
Withdrawn by{" "}
|
||||
<span className="font-medium text-foreground">{resolvedByLabel ?? "an agent"}</span>
|
||||
{adminReason ? (
|
||||
<div className="mt-1 italic text-muted-foreground/90">"{adminReason}"</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : adminOutcome === "issue_closed" && interaction.resolvedAt ? (
|
||||
// The header badge + body already explain the issue-closed expiry;
|
||||
// the footer is just the audit timestamp.
|
||||
<div
|
||||
className="mt-4 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-issue-closed-footer"
|
||||
>
|
||||
{formatShortDate(interaction.resolvedAt)}
|
||||
</div>
|
||||
) : resolvedByLabel && !isToolAction ? (
|
||||
<div
|
||||
className="mt-4 flex flex-wrap items-center gap-x-1 gap-y-0.5 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-resolved-footer"
|
||||
>
|
||||
Resolved by <span className="font-medium text-foreground">{resolvedByLabel}</span>
|
||||
{resolvedByAgent ? <ResolvedByAgentChip /> : null}
|
||||
{interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""}
|
||||
</div>
|
||||
{adminReason ? (
|
||||
<div className="mt-1 italic text-muted-foreground/90">"{adminReason}"</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : adminOutcome === "issue_closed" && interaction.resolvedAt ? (
|
||||
// The header badge + body already explain the issue-closed expiry;
|
||||
// the footer is just the audit timestamp.
|
||||
<div
|
||||
className="mt-4 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-issue-closed-footer"
|
||||
>
|
||||
{formatShortDate(interaction.resolvedAt)}
|
||||
</div>
|
||||
) : resolvedByLabel && !isToolAction ? (
|
||||
<div
|
||||
className="mt-4 flex flex-wrap items-center gap-x-1 gap-y-0.5 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-resolved-footer"
|
||||
>
|
||||
Resolved by <span className="font-medium text-foreground">{resolvedByLabel}</span>
|
||||
{resolvedByAgent ? <ResolvedByAgentChip /> : null}
|
||||
{interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</InteractionAudienceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@ function createRequestConfirmation(
|
|||
summary: "Review and approve the latest plan.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
resolverPolicy: "board_only",
|
||||
requestedResolverPolicy: "board_only",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
resolverPolicy: "anyone",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
createdByAgentId: "agent-1",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,12 @@ function confirmation(
|
|||
summary: null,
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
resolverPolicy: "board_only",
|
||||
requestedResolverPolicy: "board_only",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
resolverPolicy: "anyone",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
createdByAgentId: "agent-1",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { legacyIssueThreadInteractionResolverPolicyAlias } from "@paperclipai/shared";
|
||||
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
|
|
@ -6,6 +7,7 @@ import type {
|
|||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import type {
|
||||
AskUserQuestionsInteraction,
|
||||
IssueThreadInteractionBase,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
RequestConfirmationToolActionPayload,
|
||||
|
|
@ -20,6 +22,42 @@ export const issueThreadInteractionFixtureMeta = {
|
|||
currentUserId: "user-board",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolver-audience snapshot fields shared by every interaction fixture.
|
||||
*
|
||||
* The default is the open audience: `anyone` with `inherited` provenance, which
|
||||
* is what the server returns for a create request that omits `resolverPolicy`
|
||||
* (PAP-17277 contract, PAP-17280 surfaces). A fixture that wants a restriction
|
||||
* states it explicitly, exactly as a requester must.
|
||||
*/
|
||||
function resolverAudienceFields(
|
||||
overrides: Partial<IssueThreadInteractionBase>,
|
||||
): Pick<
|
||||
IssueThreadInteractionBase,
|
||||
| "resolverPolicy"
|
||||
| "requestedResolverPolicy"
|
||||
| "effectiveResolverPolicy"
|
||||
| "resolverPolicyProvenance"
|
||||
| "effectiveResolverPolicySource"
|
||||
| "legacyResolverPolicyAliases"
|
||||
> {
|
||||
const requested = overrides.requestedResolverPolicy ?? overrides.resolverPolicy ?? "anyone";
|
||||
const effective = overrides.effectiveResolverPolicy ?? requested;
|
||||
return {
|
||||
resolverPolicy: requested,
|
||||
requestedResolverPolicy: requested,
|
||||
effectiveResolverPolicy: effective,
|
||||
resolverPolicyProvenance:
|
||||
overrides.resolverPolicyProvenance ?? (requested === "anyone" ? "inherited" : "explicit"),
|
||||
effectiveResolverPolicySource:
|
||||
overrides.effectiveResolverPolicySource ?? (effective === requested ? "requested" : "company_cap"),
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases ?? {
|
||||
requested: legacyIssueThreadInteractionResolverPolicyAlias(requested),
|
||||
effective: legacyIssueThreadInteractionResolverPolicyAlias(effective),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
|
||||
const createdAt = overrides.createdAt ?? new Date("2026-04-20T14:00:00.000Z");
|
||||
return {
|
||||
|
|
@ -105,9 +143,7 @@ function createSuggestTasksInteraction(
|
|||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
...resolverAudienceFields(overrides),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -184,9 +220,7 @@ function createAskUserQuestionsInteraction(
|
|||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
...resolverAudienceFields(overrides),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -230,9 +264,7 @@ function createRequestConfirmationInteraction(
|
|||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
...resolverAudienceFields(overrides),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -292,9 +324,7 @@ function createRequestCheckboxConfirmationInteraction(
|
|||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
...resolverAudienceFields(overrides),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -795,11 +825,51 @@ export const agentAddressedRequestConfirmationInteraction =
|
|||
id: "interaction-confirmation-agent-addressed",
|
||||
title: "Confirm the deploy window with the release agent",
|
||||
summary:
|
||||
"Directed to the release agent, who is permitted to resolve this without waiting on the board.",
|
||||
"Directed to the release agent, who owns this response without waiting on the board.",
|
||||
addresseeAgentId: "agent-codex",
|
||||
requestedResolverPolicy: "board_or_agents",
|
||||
resolverPolicy: "board_or_agents",
|
||||
effectiveResolverPolicy: "board_or_agents",
|
||||
});
|
||||
|
||||
// --- Explicit resolver restrictions (PAP-17280) ---
|
||||
// Every card above is open by default; these four are the deliberate narrowings
|
||||
// a requester or a company must ask for, one per audience row the card renders.
|
||||
|
||||
/** Independent review requested on purpose: the creator is excluded. */
|
||||
export const notCreatorRequestConfirmationInteraction =
|
||||
createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-not-creator",
|
||||
title: "Independent review of the migration plan",
|
||||
summary: "Asked for a second pair of eyes, so the agent that wrote the plan cannot approve it.",
|
||||
requestedResolverPolicy: "not_creator",
|
||||
});
|
||||
|
||||
/** A decision reserved for a person. */
|
||||
export const humanOnlyRequestConfirmationInteraction =
|
||||
createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-human-only",
|
||||
title: "Approve the customer-facing announcement",
|
||||
summary: "Reserved for a human on the board because it commits the company publicly.",
|
||||
requestedResolverPolicy: "human_only",
|
||||
});
|
||||
|
||||
/** Open request narrowed by company interaction governance. */
|
||||
export const companyCappedRequestConfirmationInteraction =
|
||||
createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-company-capped",
|
||||
title: "Confirm the data retention change",
|
||||
summary: "Asked for an open audience; the company caps this kind at a human decision.",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
effectiveResolverPolicySource: "company_cap",
|
||||
});
|
||||
|
||||
/** Pre-migration card whose provenance cannot prove it was ever open. */
|
||||
export const legacyRestrictedRequestConfirmationInteraction =
|
||||
createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-legacy-restricted",
|
||||
title: "Confirm the archived cleanup batch",
|
||||
summary: "Created before Anyone became the default, so it stays restricted until re-created.",
|
||||
requestedResolverPolicy: "not_creator",
|
||||
resolverPolicyProvenance: "legacy_inherited_restriction",
|
||||
});
|
||||
|
||||
// Confirmation resolved by an agent under governance: exercises the
|
||||
|
|
@ -812,9 +882,6 @@ export const agentResolvedRequestConfirmationInteraction =
|
|||
createdByAgentId: "agent-codex",
|
||||
resolvedByAgentId: "agent-codex",
|
||||
resolvedByRunId: "run-agent-resolve-1",
|
||||
requestedResolverPolicy: "board_or_agents",
|
||||
resolverPolicy: "board_or_agents",
|
||||
effectiveResolverPolicy: "board_or_agents",
|
||||
resolvedAt: new Date("2026-04-20T15:05:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:05:00.000Z"),
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
|
|
@ -1065,9 +1132,7 @@ function createRequestItemVerdictsInteraction(
|
|||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
...resolverAudienceFields(overrides),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { AttentionItem, AttentionResolverAudience } from "@paperclipai/shared";
|
||||
import type { RequestConfirmationInteraction } from "./issue-thread-interactions";
|
||||
import {
|
||||
DEFAULT_RESOLVER_POLICY,
|
||||
RESOLVER_POLICY_CHOICES,
|
||||
describeAttentionResolverAudience,
|
||||
describeInteractionAudience,
|
||||
resolverPolicyEffect,
|
||||
resolverPolicyLabel,
|
||||
} from "./interaction-audience";
|
||||
|
||||
function confirmation(
|
||||
overrides: Partial<RequestConfirmationInteraction> = {},
|
||||
): RequestConfirmationInteraction {
|
||||
return {
|
||||
id: "interaction-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "agent-creator",
|
||||
createdByUserId: null,
|
||||
createdAt: "2026-08-14T12:00:00.000Z",
|
||||
updatedAt: "2026-08-14T12:00:00.000Z",
|
||||
payload: { version: 1, prompt: "Ship it?" },
|
||||
result: null,
|
||||
// Open default with inherited provenance — what an omitted `resolverPolicy`
|
||||
// produces under the PAP-17277 contract.
|
||||
resolverPolicy: "anyone",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolver policy vocabulary", () => {
|
||||
it("defaults to the open audience", () => {
|
||||
expect(DEFAULT_RESOLVER_POLICY).toBe("anyone");
|
||||
expect(RESOLVER_POLICY_CHOICES[0]).toMatchObject({ value: "anyone", isDefault: true });
|
||||
expect(RESOLVER_POLICY_CHOICES.map((choice) => choice.value)).toEqual([
|
||||
"anyone",
|
||||
"not_creator",
|
||||
"human_only",
|
||||
]);
|
||||
expect(RESOLVER_POLICY_CHOICES.filter((choice) => choice.isDefault)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("labels each canonical policy in plain language", () => {
|
||||
expect(resolverPolicyLabel("anyone")).toBe("Anyone");
|
||||
expect(resolverPolicyLabel("not_creator")).toBe("Anyone except creator");
|
||||
expect(resolverPolicyLabel("human_only")).toBe("Human only");
|
||||
});
|
||||
|
||||
it("maps deprecated board aliases onto canonical copy", () => {
|
||||
expect(resolverPolicyLabel("board_or_agents")).toBe("Anyone");
|
||||
expect(resolverPolicyLabel("board_only")).toBe("Human only");
|
||||
expect(resolverPolicyEffect("board_only")).toBe(resolverPolicyEffect("human_only"));
|
||||
});
|
||||
|
||||
it("previews an effect for every choice without naming a raw policy value", () => {
|
||||
for (const choice of RESOLVER_POLICY_CHOICES) {
|
||||
expect(choice.effect.length).toBeGreaterThan(0);
|
||||
expect(choice.effect).not.toContain(choice.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeInteractionAudience", () => {
|
||||
it("reads an omitted policy as open company attention", () => {
|
||||
const audience = describeInteractionAudience({ interaction: confirmation() });
|
||||
expect(audience.policy).toBe("anyone");
|
||||
expect(audience.isOpen).toBe(true);
|
||||
expect(audience.label).toBe("Anyone");
|
||||
expect(audience.narrowedBy).toBeNull();
|
||||
expect(audience.narrowedNote).toBeNull();
|
||||
expect(audience.summary).toBe(
|
||||
"Anyone in the company can respond — the board or any agent, including the one that asked.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not present an open card as board-required", () => {
|
||||
const audience = describeInteractionAudience({ interaction: confirmation() });
|
||||
expect(audience.summary).not.toMatch(/only .*board/i);
|
||||
expect(audience.summary).toMatch(/anyone in the company/i);
|
||||
});
|
||||
|
||||
it("names the excluded creator for an explicit not_creator restriction", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
requestedResolverPolicy: "not_creator",
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
legacyResolverPolicyAliases: { requested: null, effective: null },
|
||||
}),
|
||||
creatorLabel: "ClaudeCoder",
|
||||
});
|
||||
expect(audience.isOpen).toBe(false);
|
||||
expect(audience.label).toBe("Anyone except creator");
|
||||
expect(audience.narrowedBy).toBe("requested");
|
||||
expect(audience.summary).toBe("Anyone in the company except ClaudeCoder can respond.");
|
||||
// An explicitly requested restriction needs no extra explanation.
|
||||
expect(audience.narrowedNote).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to a generic creator phrase when the creator label is unknown", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
requestedResolverPolicy: "not_creator",
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
}),
|
||||
});
|
||||
expect(audience.summary).toBe(
|
||||
"Anyone in the company except the agent that created it can respond.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps human-only ownership copy explicit", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
legacyResolverPolicyAliases: { requested: "board_only", effective: "board_only" },
|
||||
}),
|
||||
});
|
||||
expect(audience.label).toBe("Human only");
|
||||
expect(audience.isOpen).toBe(false);
|
||||
expect(audience.summary).toBe(
|
||||
"Only a person on the board can respond — agents cannot resolve this card.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps addressee ownership copy when a named agent owns the card", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({ addresseeAgentId: "agent-release" }),
|
||||
addresseeLabel: "ReleaseBot",
|
||||
});
|
||||
expect(audience.isOpen).toBe(false);
|
||||
expect(audience.narrowedBy).toBe("addressee");
|
||||
expect(audience.summary).toBe("Only ReleaseBot or a person on the board can respond.");
|
||||
// The label must not claim "Anyone" next to a sentence naming one agent.
|
||||
expect(audience.label).toBe("Addressed");
|
||||
});
|
||||
|
||||
it("lets human_only win over a named addressee", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
addresseeAgentId: "agent-release",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
}),
|
||||
addresseeLabel: "ReleaseBot",
|
||||
});
|
||||
expect(audience.summary).toBe(
|
||||
"Only a person on the board can respond — agents cannot resolve this card.",
|
||||
);
|
||||
expect(audience.label).toBe("Human only");
|
||||
});
|
||||
|
||||
it("explains a governed-action clamp", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
effectiveResolverPolicySource: "governed_action",
|
||||
}),
|
||||
});
|
||||
expect(audience.policy).toBe("human_only");
|
||||
expect(audience.requestedPolicy).toBe("anyone");
|
||||
expect(audience.narrowedBy).toBe("governed_action");
|
||||
expect(audience.narrowedNote).toBe(
|
||||
"This card runs a governed action, so it stays human-only whatever audience was requested.",
|
||||
);
|
||||
});
|
||||
|
||||
it("explains a company cap using both audience labels", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
effectiveResolverPolicySource: "company_cap",
|
||||
}),
|
||||
});
|
||||
expect(audience.narrowedBy).toBe("company_cap");
|
||||
expect(audience.narrowedNote).toBe(
|
||||
"Company interaction governance narrowed this from Anyone to Human only.",
|
||||
);
|
||||
});
|
||||
|
||||
it("explains a legacy restricted card that predates the open default", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
requestedResolverPolicy: "not_creator",
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
resolverPolicyProvenance: "legacy_inherited_restriction",
|
||||
}),
|
||||
creatorLabel: "ClaudeCoder",
|
||||
});
|
||||
expect(audience.narrowedBy).toBe("legacy_restriction");
|
||||
expect(audience.narrowedNote).toBe(
|
||||
"Created before Anyone became the default, so it stays restricted. A new card would be open.",
|
||||
);
|
||||
expect(audience.summary).toBe("Anyone in the company except ClaudeCoder can respond.");
|
||||
});
|
||||
});
|
||||
|
||||
/** The `resolverAudience` metadata the attention feed ships with an interaction row. */
|
||||
function attentionAudience(
|
||||
overrides: Partial<AttentionResolverAudience> = {},
|
||||
): AttentionResolverAudience {
|
||||
return {
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
addresseeAgentId: null,
|
||||
addresseeName: null,
|
||||
createdByAgentId: "agent-creator",
|
||||
createdByAgentName: "Watchdog",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function attentionItem(
|
||||
audience: AttentionResolverAudience | null,
|
||||
sourceKind: AttentionItem["sourceKind"] = "issue_thread_interaction",
|
||||
): Pick<AttentionItem, "sourceKind" | "resolverAudience"> {
|
||||
return { sourceKind, resolverAudience: audience };
|
||||
}
|
||||
|
||||
describe("glanceable audience copy", () => {
|
||||
it("states the open default in one clause", () => {
|
||||
expect(describeInteractionAudience({ interaction: confirmation() }).shortSummary)
|
||||
.toBe("Anyone can respond");
|
||||
});
|
||||
|
||||
it("names the excluded creator", () => {
|
||||
expect(describeInteractionAudience({
|
||||
interaction: confirmation({ requestedResolverPolicy: "not_creator", effectiveResolverPolicy: "not_creator" }),
|
||||
creatorLabel: "Watchdog",
|
||||
}).shortSummary).toBe("Anyone except Watchdog can respond");
|
||||
});
|
||||
|
||||
it("names the addressed responder", () => {
|
||||
expect(describeInteractionAudience({
|
||||
interaction: confirmation({ addresseeAgentId: "agent-codex" }),
|
||||
addresseeLabel: "CodexCoder",
|
||||
}).shortSummary).toBe("Only CodexCoder or the board can respond");
|
||||
});
|
||||
|
||||
it("keeps human-only ownership with the board", () => {
|
||||
expect(describeInteractionAudience({
|
||||
interaction: confirmation({ requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only" }),
|
||||
}).shortSummary).toBe("Only the board can respond");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeAttentionResolverAudience", () => {
|
||||
it("describes an open interaction row from server metadata alone", () => {
|
||||
const audience = describeAttentionResolverAudience(attentionItem(attentionAudience()));
|
||||
expect(audience?.policy).toBe("anyone");
|
||||
expect(audience?.isOpen).toBe(true);
|
||||
expect(audience?.label).toBe("Anyone");
|
||||
expect(audience?.shortSummary).toBe("Anyone can respond");
|
||||
});
|
||||
|
||||
it("names the addressed agent the server recorded", () => {
|
||||
const audience = describeAttentionResolverAudience(attentionItem(attentionAudience({
|
||||
addresseeAgentId: "agent-codex",
|
||||
addresseeName: "CodexCoder",
|
||||
})));
|
||||
expect(audience?.label).toBe("Addressed");
|
||||
expect(audience?.shortSummary).toBe("Only CodexCoder or the board can respond");
|
||||
});
|
||||
|
||||
it("names the excluded creator the server recorded", () => {
|
||||
const audience = describeAttentionResolverAudience(attentionItem(attentionAudience({
|
||||
requestedResolverPolicy: "not_creator",
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
})));
|
||||
expect(audience?.shortSummary).toBe("Anyone except Watchdog can respond");
|
||||
});
|
||||
|
||||
it("carries the narrowing explanation for a company cap", () => {
|
||||
const audience = describeAttentionResolverAudience(attentionItem(attentionAudience({
|
||||
effectiveResolverPolicy: "human_only",
|
||||
effectiveResolverPolicySource: "company_cap",
|
||||
})));
|
||||
expect(audience?.narrowedBy).toBe("company_cap");
|
||||
expect(audience?.narrowedNote).toBe(
|
||||
"Company interaction governance narrowed this from Anyone to Human only.",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays silent rather than guessing a policy client-side", () => {
|
||||
// No metadata (an older feed) and non-interaction rows must render nothing.
|
||||
expect(describeAttentionResolverAudience(attentionItem(null))).toBeNull();
|
||||
expect(describeAttentionResolverAudience({ sourceKind: "issue_thread_interaction" })).toBeNull();
|
||||
expect(describeAttentionResolverAudience(attentionItem(attentionAudience(), "approval"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
/**
|
||||
* Plain-language presentation of *who may resolve* an issue-thread interaction
|
||||
* (PAP-17280, Phase 3 of the open-default resolver contract in
|
||||
* `doc/SPEC-implementation.md` §9.8.1).
|
||||
*
|
||||
* The product default is open: an interaction created without an explicit
|
||||
* `resolverPolicy` is resolvable by `anyone` in the company — the board or any
|
||||
* agent, including the agent that created it. `not_creator`, `human_only`, and a
|
||||
* named addressee are *narrowing* controls that a requester asks for on purpose.
|
||||
*
|
||||
* This module is presentation only. It never decides authority: the server
|
||||
* evaluator owns that, and every downstream effect re-runs its own
|
||||
* authorization (see the Phase 1S security verdict on PAP-17278). Everything
|
||||
* here is derived from the server-provided snapshot fields
|
||||
* (`requestedResolverPolicy`, `effectiveResolverPolicy`,
|
||||
* `effectiveResolverPolicySource`, `resolverPolicyProvenance`) so the copy a
|
||||
* reader sees agrees with the policy the server will apply.
|
||||
*/
|
||||
|
||||
import {
|
||||
ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES,
|
||||
normalizeIssueThreadInteractionResolverPolicy,
|
||||
type AttentionItem,
|
||||
type AttentionResolverAudience,
|
||||
type IssueThreadInteractionCanonicalResolverPolicy,
|
||||
type IssueThreadInteractionEffectiveResolverPolicySource,
|
||||
type IssueThreadInteractionResolverPolicy,
|
||||
type IssueThreadInteractionResolverPolicyProvenance,
|
||||
} from "@paperclipai/shared";
|
||||
import type { IssueThreadInteraction } from "./issue-thread-interactions";
|
||||
|
||||
/**
|
||||
* The open default. Creation surfaces present this first and requesters omit
|
||||
* `resolverPolicy` to get it.
|
||||
*/
|
||||
export const DEFAULT_RESOLVER_POLICY: IssueThreadInteractionCanonicalResolverPolicy = "anyone";
|
||||
|
||||
/** Short label for a resolver audience — badges, select options, table cells. */
|
||||
const RESOLVER_POLICY_LABELS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
|
||||
anyone: "Anyone",
|
||||
not_creator: "Anyone except creator",
|
||||
human_only: "Human only",
|
||||
};
|
||||
|
||||
/**
|
||||
* Plain-language preview of what a policy *does*, phrased for a surface that is
|
||||
* choosing it for future cards (company defaults and caps). Card-specific copy
|
||||
* that names the real creator/addressee comes from
|
||||
* {@link describeInteractionAudience}.
|
||||
*/
|
||||
const RESOLVER_POLICY_EFFECTS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
|
||||
anyone:
|
||||
"Anyone in the company can respond — the board or any agent, including the one that asked.",
|
||||
not_creator:
|
||||
"Anyone in the company except the agent that created the card, and its run. Use this when the answer has to come from someone else.",
|
||||
human_only: "Only a person on the board can respond. Agents are turned away.",
|
||||
};
|
||||
|
||||
/** Accepts canonical values and the deprecated `board_*` compatibility aliases. */
|
||||
export function resolverPolicyLabel(policy: IssueThreadInteractionResolverPolicy): string {
|
||||
return RESOLVER_POLICY_LABELS[normalizeIssueThreadInteractionResolverPolicy(policy)];
|
||||
}
|
||||
|
||||
/** Accepts canonical values and the deprecated `board_*` compatibility aliases. */
|
||||
export function resolverPolicyEffect(policy: IssueThreadInteractionResolverPolicy): string {
|
||||
return RESOLVER_POLICY_EFFECTS[normalizeIssueThreadInteractionResolverPolicy(policy)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered choice list for every surface that picks a resolver audience. `anyone`
|
||||
* is first and marked as the default so the open option reads as the normal
|
||||
* path rather than a permission grant.
|
||||
*/
|
||||
export const RESOLVER_POLICY_CHOICES: readonly {
|
||||
value: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
label: string;
|
||||
effect: string;
|
||||
isDefault: boolean;
|
||||
}[] = ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES.map((value) => ({
|
||||
value,
|
||||
label: RESOLVER_POLICY_LABELS[value],
|
||||
effect: RESOLVER_POLICY_EFFECTS[value],
|
||||
isDefault: value === DEFAULT_RESOLVER_POLICY,
|
||||
}));
|
||||
|
||||
/** Why an effective audience ended up narrower than the requested one. */
|
||||
export type InteractionAudienceNarrowing =
|
||||
| "requested"
|
||||
| "company_cap"
|
||||
| "governed_action"
|
||||
| "addressee"
|
||||
| "legacy_restriction";
|
||||
|
||||
export interface InteractionAudienceDescription {
|
||||
/** Effective canonical policy the server will enforce. */
|
||||
policy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
/** Canonical policy the creator asked for (before caps/clamps). */
|
||||
requestedPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
/** Short badge label for the effective audience. */
|
||||
label: string;
|
||||
/** One sentence naming exactly who can respond to *this* card. */
|
||||
summary: string;
|
||||
/**
|
||||
* The same fact in a glanceable clause, for a dense surface that shows the
|
||||
* audience beside compact decision buttons (collapsed attention rows).
|
||||
*/
|
||||
shortSummary: string;
|
||||
/** True when the card is open to the whole company with no addressee. */
|
||||
isOpen: boolean;
|
||||
/** Set when something narrows the audience below the open default. */
|
||||
narrowedBy: InteractionAudienceNarrowing | null;
|
||||
/** Extra sentence explaining a narrowing the requester did not ask for. */
|
||||
narrowedNote: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server-evaluated facts an audience description is derived from. Both the
|
||||
* full interaction snapshot (issue thread) and the attention feed's
|
||||
* `resolverAudience` (collapsed queue rows) reduce to this shape, so one copy
|
||||
* table serves both surfaces and they cannot drift.
|
||||
*/
|
||||
export interface InteractionAudienceFacts {
|
||||
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
requestedResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
||||
effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource;
|
||||
resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance;
|
||||
hasAddressee: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the effective audience of one interaction.
|
||||
*
|
||||
* Precedence mirrors the server evaluator's *narrowing* order so the copy can
|
||||
* never promise a wider audience than the API allows: `human_only` (including a
|
||||
* governed-action clamp) beats a named addressee, which beats `not_creator`,
|
||||
* which beats the open default.
|
||||
*/
|
||||
export function describeInteractionAudience({
|
||||
interaction,
|
||||
creatorLabel,
|
||||
addresseeLabel,
|
||||
}: {
|
||||
interaction: IssueThreadInteraction;
|
||||
/** Display label of the creating actor, when known. */
|
||||
creatorLabel?: string | null;
|
||||
/** Display label of the named addressee agent, when the card has one. */
|
||||
addresseeLabel?: string | null;
|
||||
}): InteractionAudienceDescription {
|
||||
return describeResolverAudience({
|
||||
facts: {
|
||||
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
|
||||
requestedResolverPolicy: interaction.requestedResolverPolicy,
|
||||
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
|
||||
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
|
||||
hasAddressee: Boolean(interaction.addresseeAgentId),
|
||||
},
|
||||
creatorLabel,
|
||||
addresseeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe an effective audience from the server-evaluated facts alone — used
|
||||
* where the full interaction is not loaded, such as a collapsed attention row
|
||||
* (PAP-17287).
|
||||
*/
|
||||
export function describeResolverAudience({
|
||||
facts,
|
||||
creatorLabel,
|
||||
addresseeLabel,
|
||||
}: {
|
||||
facts: InteractionAudienceFacts;
|
||||
creatorLabel?: string | null;
|
||||
addresseeLabel?: string | null;
|
||||
}): InteractionAudienceDescription {
|
||||
const policy = facts.effectiveResolverPolicy;
|
||||
const requestedPolicy = facts.requestedResolverPolicy;
|
||||
const hasAddressee = facts.hasAddressee;
|
||||
const addressee = addresseeLabel?.trim() || "the addressed agent";
|
||||
const creator = creatorLabel?.trim() || "the agent that created it";
|
||||
|
||||
const summary = policy === "human_only"
|
||||
? "Only a person on the board can respond — agents cannot resolve this card."
|
||||
: hasAddressee
|
||||
? `Only ${addressee} or a person on the board can respond.`
|
||||
: policy === "not_creator"
|
||||
? `Anyone in the company except ${creator} can respond.`
|
||||
: "Anyone in the company can respond — the board or any agent, including the one that asked.";
|
||||
|
||||
// Same fact, fewer words: a collapsed row has to answer "is this mine to
|
||||
// decide?" in one glance, next to the buttons that act on the answer.
|
||||
const shortSummary = policy === "human_only"
|
||||
? "Only the board can respond"
|
||||
: hasAddressee
|
||||
? `Only ${addressee} or the board can respond`
|
||||
: policy === "not_creator"
|
||||
? `Anyone except ${creator} can respond`
|
||||
: "Anyone can respond";
|
||||
|
||||
const source = facts.effectiveResolverPolicySource;
|
||||
const provenance = facts.resolverPolicyProvenance;
|
||||
|
||||
// Narrowing the requester did *not* ask for is the only thing worth an extra
|
||||
// sentence: a governed-action clamp, a company cap, or a card created before
|
||||
// open resolution existed. An explicitly requested restriction is already
|
||||
// fully described by `summary`.
|
||||
const narrowedNote = source === "governed_action"
|
||||
? "This card runs a governed action, so it stays human-only whatever audience was requested."
|
||||
: source === "company_cap"
|
||||
? `Company interaction governance narrowed this from ${RESOLVER_POLICY_LABELS[requestedPolicy]} to ${RESOLVER_POLICY_LABELS[policy]}.`
|
||||
: provenance === "legacy_inherited_restriction"
|
||||
? "Created before Anyone became the default, so it stays restricted. A new card would be open."
|
||||
: null;
|
||||
|
||||
const narrowedBy: InteractionAudienceNarrowing | null = source === "governed_action"
|
||||
? "governed_action"
|
||||
: source === "company_cap"
|
||||
? "company_cap"
|
||||
: provenance === "legacy_inherited_restriction"
|
||||
? "legacy_restriction"
|
||||
: policy !== "anyone"
|
||||
? "requested"
|
||||
: hasAddressee
|
||||
? "addressee"
|
||||
: null;
|
||||
|
||||
return {
|
||||
policy,
|
||||
requestedPolicy,
|
||||
// A named addressee owns the response, so the label must not read "Anyone"
|
||||
// while the sentence next to it names one agent. `human_only` still wins,
|
||||
// because an addressed agent cannot resolve a human-only card.
|
||||
label: policy !== "human_only" && hasAddressee
|
||||
? "Addressed"
|
||||
: RESOLVER_POLICY_LABELS[policy],
|
||||
summary,
|
||||
shortSummary,
|
||||
isOpen: policy === "anyone" && !hasAddressee,
|
||||
narrowedBy,
|
||||
narrowedNote,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Audience of an `issue_thread_interaction` attention row, from the server
|
||||
* metadata the feed ships with the item (PAP-17287). Returns `null` for every
|
||||
* other source kind and for a feed built before the metadata existed, so a
|
||||
* caller renders nothing rather than guessing a policy client-side.
|
||||
*/
|
||||
export function describeAttentionResolverAudience(
|
||||
item: Pick<AttentionItem, "sourceKind" | "resolverAudience">,
|
||||
): InteractionAudienceDescription | null {
|
||||
if (item.sourceKind !== "issue_thread_interaction") return null;
|
||||
const audience: AttentionResolverAudience | null | undefined = item.resolverAudience;
|
||||
if (!audience) return null;
|
||||
return describeResolverAudience({
|
||||
facts: {
|
||||
effectiveResolverPolicy: audience.effectiveResolverPolicy,
|
||||
requestedResolverPolicy: audience.requestedResolverPolicy,
|
||||
effectiveResolverPolicySource: audience.effectiveResolverPolicySource,
|
||||
resolverPolicyProvenance: audience.resolverPolicyProvenance,
|
||||
hasAddressee: Boolean(audience.addresseeAgentId),
|
||||
},
|
||||
creatorLabel: audience.createdByAgentName,
|
||||
addresseeLabel: audience.addresseeName,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ApiError } from "../api/client";
|
||||
import {
|
||||
describeInteractionResolutionFailure,
|
||||
interactionResolutionErrorCode,
|
||||
interactionResolutionErrorMessage,
|
||||
isInteractionAudienceDenial,
|
||||
} from "./interaction-resolution-error";
|
||||
|
||||
const addressedAudience = { shortSummary: "Only CodexCoder or the board can respond", isOpen: false };
|
||||
const humanOnlyAudience = { shortSummary: "Only the board can respond", isOpen: false };
|
||||
const openAudience = { shortSummary: "Anyone can respond", isOpen: true };
|
||||
|
||||
/** Shapes an `ApiError` the way `errorHandler` serializes an `HttpError`. */
|
||||
function denial(status: number, code: string, message: string) {
|
||||
return new ApiError(message, status, { error: message, code, details: { code } });
|
||||
}
|
||||
|
||||
describe("interactionResolutionErrorCode", () => {
|
||||
it("reads the top-level code the API returns", () => {
|
||||
expect(interactionResolutionErrorCode(denial(403, "interaction_human_only", "human-only"))).toBe(
|
||||
"interaction_human_only",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the code nested in details", () => {
|
||||
const error = new ApiError("nope", 403, { error: "nope", details: { code: "interaction_addressee_mismatch" } });
|
||||
expect(interactionResolutionErrorCode(error)).toBe("interaction_addressee_mismatch");
|
||||
});
|
||||
|
||||
it("returns null for an error with no structured body", () => {
|
||||
expect(interactionResolutionErrorCode(new Error("network down"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInteractionAudienceDenial", () => {
|
||||
it("recognizes every audience denial code the evaluator can return", () => {
|
||||
for (const code of [
|
||||
"interaction_human_only",
|
||||
"interaction_creator_excluded",
|
||||
"interaction_addressee_mismatch",
|
||||
"interaction_governed_action_denied",
|
||||
"interaction_run_attribution_required",
|
||||
"interaction_scope_denied",
|
||||
]) {
|
||||
expect(isInteractionAudienceDenial(denial(403, code, "denied"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an uncoded 403 as an authorization refusal", () => {
|
||||
expect(isInteractionAudienceDenial(new ApiError("Forbidden", 403, { error: "Forbidden" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a server fault or a settled card as an audience denial", () => {
|
||||
expect(isInteractionAudienceDenial(new ApiError("boom", 500, { error: "boom" }))).toBe(false);
|
||||
expect(isInteractionAudienceDenial(denial(409, "interaction_already_resolved", "already resolved"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeInteractionResolutionFailure", () => {
|
||||
it("keeps the server denial reason and names who can respond", () => {
|
||||
const failure = describeInteractionResolutionFailure(
|
||||
denial(403, "interaction_human_only", "This issue-thread interaction is human-only"),
|
||||
humanOnlyAudience,
|
||||
);
|
||||
|
||||
expect(failure.kind).toBe("audience_denied");
|
||||
expect(failure.code).toBe("interaction_human_only");
|
||||
expect(failure.message).toBe(
|
||||
"This issue-thread interaction is human-only. Only the board can respond.",
|
||||
);
|
||||
// A policy denial is permanent: never invite a retry that will fail again.
|
||||
expect(failure.message).not.toMatch(/try again/i);
|
||||
});
|
||||
|
||||
it("names the addressed responder for an addressee mismatch", () => {
|
||||
expect(
|
||||
interactionResolutionErrorMessage(
|
||||
denial(
|
||||
403,
|
||||
"interaction_addressee_mismatch",
|
||||
"Only the addressed agent or an authorized human may resolve this issue-thread interaction",
|
||||
),
|
||||
addressedAudience,
|
||||
),
|
||||
).toContain("Only CodexCoder or the board can respond.");
|
||||
});
|
||||
|
||||
it("still explains a denial when no audience is known", () => {
|
||||
const failure = describeInteractionResolutionFailure(
|
||||
denial(403, "interaction_creator_excluded", "This issue-thread interaction requires a resolver other than its creator"),
|
||||
null,
|
||||
);
|
||||
|
||||
expect(failure.message).toBe(
|
||||
"This issue-thread interaction requires a resolver other than its creator.",
|
||||
);
|
||||
expect(failure.message).not.toMatch(/try again/i);
|
||||
});
|
||||
|
||||
// PAP-17289: the responder clause explains a denial, so it must never
|
||||
// contradict it. An open card has no narrower audience to point at.
|
||||
it("does not append the open-default clause to a refusal it would refute", () => {
|
||||
const failure = describeInteractionResolutionFailure(
|
||||
denial(
|
||||
403,
|
||||
"interaction_addressee_mismatch",
|
||||
"Only the addressed agent can resolve this interaction",
|
||||
),
|
||||
openAudience,
|
||||
);
|
||||
|
||||
expect(failure.kind).toBe("audience_denied");
|
||||
expect(failure.message).toBe("Only the addressed agent can resolve this interaction.");
|
||||
expect(failure.message).not.toMatch(/anyone can respond/i);
|
||||
});
|
||||
|
||||
it("keeps the server's own text for an uncoded 403 instead of inventing a cause", () => {
|
||||
const failure = describeInteractionResolutionFailure(
|
||||
new ApiError("Forbidden", 403, { error: "Forbidden" }),
|
||||
openAudience,
|
||||
);
|
||||
|
||||
expect(failure).toMatchObject({ kind: "audience_denied", code: null, message: "Forbidden." });
|
||||
expect(failure.message).not.toMatch(/resolver audience/i);
|
||||
expect(failure.message).not.toMatch(/try again/i);
|
||||
});
|
||||
|
||||
it("restates the refusal without claiming an audience cause when a 403 says nothing", () => {
|
||||
const failure = describeInteractionResolutionFailure(new ApiError("", 403, null), null);
|
||||
|
||||
expect(failure.message).toBe("You do not have permission to respond to this card.");
|
||||
expect(failure.message).not.toMatch(/resolver audience/i);
|
||||
});
|
||||
|
||||
it("does not ask for a retry when the card has already moved on", () => {
|
||||
const failure = describeInteractionResolutionFailure(
|
||||
denial(409, "interaction_superseded", "This confirmation was superseded by a newer request"),
|
||||
humanOnlyAudience,
|
||||
);
|
||||
|
||||
expect(failure.kind).toBe("settled");
|
||||
expect(failure.message).toBe("This confirmation was superseded by a newer request.");
|
||||
expect(failure.message).not.toMatch(/try again/i);
|
||||
});
|
||||
|
||||
it("keeps the retry prompt for a genuinely transient failure", () => {
|
||||
expect(
|
||||
describeInteractionResolutionFailure(new ApiError("Request failed: 503", 503, null), humanOnlyAudience),
|
||||
).toMatchObject({ kind: "transient", message: "Request failed: 503. Try again." });
|
||||
});
|
||||
|
||||
it("falls back to generic copy when nothing explains the failure", () => {
|
||||
expect(describeInteractionResolutionFailure(undefined, null)).toMatchObject({
|
||||
kind: "transient",
|
||||
message: "Couldn't submit. Try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves punctuation the server already wrote alone", () => {
|
||||
expect(
|
||||
interactionResolutionErrorMessage(denial(403, "interaction_human_only", "Agents cannot resolve this."), null),
|
||||
).toBe("Agents cannot resolve this.");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Plain-language copy for a *failed* issue-thread interaction resolution
|
||||
* (PAP-17287).
|
||||
*
|
||||
* The resolution routes deny with a specific reason — human-only, creator
|
||||
* excluded, addressee mismatch, a governed action, an already-resolved card.
|
||||
* Collapsing all of that to `Try again` tells an operator to repeat an action
|
||||
* that will never succeed, so this module keeps the server's explanation and,
|
||||
* for an audience denial, follows it with who *can* respond.
|
||||
*
|
||||
* Presentation only. Authority is the server's: nothing here decides whether an
|
||||
* action is permitted, it only explains the answer the server already gave.
|
||||
*/
|
||||
|
||||
import type { InteractionAudienceDescription } from "./interaction-audience";
|
||||
|
||||
/**
|
||||
* Denials that mean "you are outside this card's resolver audience". Mirrors the
|
||||
* codes the server's audience evaluator can return
|
||||
* (`server/src/services/issue-thread-interaction-resolution.ts`), plus the
|
||||
* scope denial raised before it.
|
||||
*/
|
||||
export const INTERACTION_AUDIENCE_DENIAL_CODES = [
|
||||
"interaction_human_only",
|
||||
"interaction_creator_excluded",
|
||||
"interaction_addressee_mismatch",
|
||||
"interaction_governed_action_denied",
|
||||
"interaction_run_attribution_required",
|
||||
"interaction_scope_denied",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Denials that are permanent for a different reason: the card moved on. Retrying
|
||||
* cannot help, so these lose the retry prompt too.
|
||||
*/
|
||||
const INTERACTION_SETTLED_CODES = [
|
||||
"interaction_not_found",
|
||||
"interaction_already_resolved",
|
||||
"interaction_superseded",
|
||||
"interaction_stale_target",
|
||||
"interaction_issue_closed",
|
||||
] as const;
|
||||
|
||||
export type InteractionResolutionFailureKind = "audience_denied" | "settled" | "transient";
|
||||
|
||||
export interface InteractionResolutionFailure {
|
||||
kind: InteractionResolutionFailureKind;
|
||||
/** Copy for the inline error region. Never invites a retry that cannot work. */
|
||||
message: string;
|
||||
/** Server-provided denial code, when the response carried one. */
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The server's `code`, from either the top-level field or the details object. */
|
||||
export function interactionResolutionErrorCode(error: unknown): string | null {
|
||||
const body = record(record(error)?.body);
|
||||
if (!body) return null;
|
||||
if (typeof body.code === "string" && body.code) return body.code;
|
||||
const details = record(body.details);
|
||||
return typeof details?.code === "string" && details.code ? details.code : null;
|
||||
}
|
||||
|
||||
function errorStatus(error: unknown): number | null {
|
||||
const status = record(error)?.status;
|
||||
return typeof status === "number" ? status : null;
|
||||
}
|
||||
|
||||
/** The server's human-readable reason, preserved verbatim apart from punctuation. */
|
||||
function serverReason(error: unknown): string | null {
|
||||
const body = record(record(error)?.body);
|
||||
const fromBody = typeof body?.error === "string" ? body.error.trim() : "";
|
||||
const fromError = error instanceof Error ? error.message.trim() : "";
|
||||
const reason = fromBody || fromError;
|
||||
if (!reason) return null;
|
||||
// The API writes reasons as bare clauses ("This interaction is human-only").
|
||||
return /[.!?]$/.test(reason) ? reason : `${reason}.`;
|
||||
}
|
||||
|
||||
export function isInteractionAudienceDenial(error: unknown): boolean {
|
||||
const code = interactionResolutionErrorCode(error);
|
||||
if (code) return (INTERACTION_AUDIENCE_DENIAL_CODES as readonly string[]).includes(code);
|
||||
// A 403 with no code is still an authorization refusal, not a hiccup.
|
||||
return errorStatus(error) === 403;
|
||||
}
|
||||
|
||||
/** The audience of the card being resolved, as far as this client knows it. */
|
||||
export type InteractionResolutionAudience = Pick<
|
||||
InteractionAudienceDescription,
|
||||
"shortSummary" | "isOpen"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Classify a rejected resolution and produce the copy to show inline.
|
||||
*
|
||||
* `audience` is the effective audience of the card being resolved; when the
|
||||
* failure is an audience denial *and* that audience is narrower than the open
|
||||
* default, its short form is appended so the reader learns who can respond
|
||||
* instead of being told to retry.
|
||||
*/
|
||||
export function describeInteractionResolutionFailure(
|
||||
error: unknown,
|
||||
audience?: InteractionResolutionAudience | null,
|
||||
): InteractionResolutionFailure {
|
||||
const code = interactionResolutionErrorCode(error);
|
||||
const reason = serverReason(error);
|
||||
|
||||
if (isInteractionAudienceDenial(error)) {
|
||||
// Only a *narrowed* audience has a responder worth naming. Appending the
|
||||
// open-default clause to a refusal produces copy that refutes itself —
|
||||
// "You are not in this card's resolver audience. Anyone can respond." —
|
||||
// which happens whenever the snapshot the client holds is wider than the
|
||||
// policy the server just enforced (PAP-17289).
|
||||
const responder = audience && !audience.isOpen && audience.shortSummary
|
||||
? `${audience.shortSummary}.`
|
||||
: null;
|
||||
// Without a code the server has told us only that this is forbidden, not
|
||||
// *why*. Restate the status; do not invent a resolver-audience cause it
|
||||
// never claimed.
|
||||
const coded = code !== null
|
||||
&& (INTERACTION_AUDIENCE_DENIAL_CODES as readonly string[]).includes(code);
|
||||
return {
|
||||
kind: "audience_denied",
|
||||
code,
|
||||
message: [
|
||||
reason
|
||||
?? (coded
|
||||
? "You are not in this card's resolver audience."
|
||||
: "You do not have permission to respond to this card."),
|
||||
responder,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (code && (INTERACTION_SETTLED_CODES as readonly string[]).includes(code)) {
|
||||
return {
|
||||
kind: "settled",
|
||||
code,
|
||||
message: reason ?? "This request is no longer waiting for a decision.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "transient",
|
||||
code,
|
||||
message: reason ? `${reason} Try again.` : "Couldn't submit. Try again.",
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience for the many call sites that only need the sentence. */
|
||||
export function interactionResolutionErrorMessage(
|
||||
error: unknown,
|
||||
audience?: InteractionResolutionAudience | null,
|
||||
): string {
|
||||
return describeInteractionResolutionFailure(error, audience).message;
|
||||
}
|
||||
|
|
@ -94,9 +94,13 @@ function createInteraction(
|
|||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
resolverPolicy: overrides.resolverPolicy ?? "anyone",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
|
||||
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
|
||||
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
|
||||
?? { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -125,9 +129,13 @@ function createRequestConfirmation(
|
|||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
resolverPolicy: overrides.resolverPolicy ?? "board_only",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only",
|
||||
resolverPolicy: overrides.resolverPolicy ?? "anyone",
|
||||
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
|
||||
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
|
||||
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
|
||||
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
|
||||
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
|
||||
?? { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -742,9 +750,12 @@ describe("buildIssueChatMessages", () => {
|
|||
createdAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||
resolvedAt: null,
|
||||
resolverPolicy: "board_only",
|
||||
requestedResolverPolicy: "board_only",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
resolverPolicy: "anyone",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
payload: { version: 1, questions },
|
||||
result: null,
|
||||
} as AskUserQuestionsInteraction;
|
||||
|
|
|
|||
|
|
@ -20,10 +20,15 @@ import type {
|
|||
RequestItemVerdictsInteraction,
|
||||
} from "./issue-thread-interactions";
|
||||
|
||||
// The open default every interaction now inherits when its creator omits
|
||||
// `resolverPolicy` (PAP-17277 contract).
|
||||
const resolverPolicyFields = {
|
||||
resolverPolicy: "board_only",
|
||||
requestedResolverPolicy: "board_only",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
resolverPolicy: "anyone",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents" },
|
||||
} as const;
|
||||
|
||||
describe("buildSuggestedTaskTree", () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { ChangeEvent, Fragment, useEffect, useState } from "react";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
ISSUE_THREAD_INTERACTION_KINDS,
|
||||
type InteractionResolverGovernance,
|
||||
type IssueThreadInteractionKind,
|
||||
type IssueThreadInteractionResolverPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
|
|
@ -15,14 +13,13 @@ import { assetsApi } from "../api/assets";
|
|||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Settings, Download, Upload } from "lucide-react";
|
||||
import {
|
||||
InteractionGovernancePanel,
|
||||
applyGovernanceChange,
|
||||
type GovernanceField,
|
||||
type GovernanceSelectValue,
|
||||
} from "../components/InteractionGovernancePanel";
|
||||
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
||||
import {
|
||||
Field,
|
||||
|
|
@ -33,100 +30,6 @@ const BYTES_PER_MIB = 1024 * 1024;
|
|||
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
|
||||
const INTERACTION_KIND_LABELS: Record<IssueThreadInteractionKind, string> = {
|
||||
suggest_tasks: "Suggested tasks",
|
||||
ask_user_questions: "Ask user questions",
|
||||
request_confirmation: "Confirmations",
|
||||
request_checkbox_confirmation: "Checkbox confirmations",
|
||||
request_item_verdicts: "Item verdicts",
|
||||
};
|
||||
|
||||
// Sentinel for "no override" — Radix Select disallows empty-string item values.
|
||||
const GOVERNANCE_UNSET = "default";
|
||||
type GovernanceSelectValue = typeof GOVERNANCE_UNSET | IssueThreadInteractionResolverPolicy;
|
||||
|
||||
const GOVERNANCE_POLICY_OPTIONS: { value: GovernanceSelectValue; label: string }[] = [
|
||||
{ value: GOVERNANCE_UNSET, label: "Company default" },
|
||||
{ value: "board_only", label: "Board only" },
|
||||
{ value: "board_or_agents", label: "Board or agents" },
|
||||
];
|
||||
|
||||
function toSelectValue(policy: IssueThreadInteractionResolverPolicy | undefined): GovernanceSelectValue {
|
||||
return policy ?? GOVERNANCE_UNSET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single (kind, field) change to a governance map immutably, pruning
|
||||
* empty entries so the persisted object stays sparse (only real overrides).
|
||||
*/
|
||||
function applyGovernanceChange(
|
||||
current: InteractionResolverGovernance,
|
||||
kind: IssueThreadInteractionKind,
|
||||
field: "defaultPolicy" | "cap",
|
||||
value: GovernanceSelectValue,
|
||||
): InteractionResolverGovernance {
|
||||
const next: InteractionResolverGovernance = { ...current };
|
||||
const entry = { ...(next[kind] ?? {}) };
|
||||
if (value === GOVERNANCE_UNSET) {
|
||||
delete entry[field];
|
||||
} else {
|
||||
entry[field] = value;
|
||||
}
|
||||
if (entry.defaultPolicy === undefined && entry.cap === undefined) {
|
||||
delete next[kind];
|
||||
} else {
|
||||
next[kind] = entry;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
function GovernanceSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
testId,
|
||||
ariaLabel,
|
||||
mobileLabel,
|
||||
}: {
|
||||
value: GovernanceSelectValue;
|
||||
onChange: (value: GovernanceSelectValue) => void;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
ariaLabel: string;
|
||||
mobileLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{/*
|
||||
* Below `sm` the governance grid collapses to a single column (see the
|
||||
* grid classes on the panel), detaching each select from its column
|
||||
* header. Surface a mobile-only inline label so the control stays
|
||||
* self-describing for sighted users, and always carry `aria-label` for
|
||||
* screen-reader pairing. WCAG 2.1 SC 1.4.10 (Reflow) — design review R2.
|
||||
*/}
|
||||
<span className="mb-1 block text-xs font-medium text-muted-foreground uppercase tracking-wide sm:hidden">
|
||||
{mobileLabel}
|
||||
</span>
|
||||
<Select value={value} onValueChange={(v) => onChange(v as GovernanceSelectValue)} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
aria-label={ariaLabel}
|
||||
className="w-full min-w-0 text-xs sm:w-(--sz-170px)"
|
||||
data-testid={testId}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GOVERNANCE_POLICY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanySettings() {
|
||||
const {
|
||||
companies,
|
||||
|
|
@ -202,7 +105,7 @@ export function CompanySettings() {
|
|||
|
||||
function handleGovernanceChange(
|
||||
kind: IssueThreadInteractionKind,
|
||||
field: "defaultPolicy" | "cap",
|
||||
field: GovernanceField,
|
||||
value: GovernanceSelectValue,
|
||||
) {
|
||||
const next = applyGovernanceChange(governance, kind, field, value);
|
||||
|
|
@ -489,73 +392,18 @@ export function CompanySettings() {
|
|||
</div>
|
||||
|
||||
{/* Interaction governance */}
|
||||
<div className="space-y-4" data-testid="company-settings-interaction-governance-section">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Interaction governance
|
||||
</div>
|
||||
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Control who may resolve each kind of thread interaction.{" "}
|
||||
<span className="font-medium text-foreground">Default policy</span> is the
|
||||
resolver policy new interactions request;{" "}
|
||||
<span className="font-medium text-foreground">Cap</span> is the maximum a
|
||||
request may reach — set it to{" "}
|
||||
<span className="font-medium text-foreground">Board only</span> to always
|
||||
require the board. Tool-approval confirmations always stay board-only
|
||||
regardless of these settings.
|
||||
</p>
|
||||
{/*
|
||||
* Responsive: below `sm` the row collapses to a single column so the
|
||||
* two 170px selects never force horizontal overflow on a ~390px
|
||||
* viewport (WCAG 2.1 SC 1.4.10 Reflow — design review R2). Each kind
|
||||
* then stacks as: label → Default policy → Cap, each full-width with
|
||||
* its own inline label. At `sm`+ it restores the aligned 3-col grid.
|
||||
*/}
|
||||
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-[1fr_auto_auto] sm:items-center sm:gap-x-4 sm:gap-y-2.5">
|
||||
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
||||
Kind
|
||||
</div>
|
||||
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
||||
Default policy
|
||||
</div>
|
||||
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
||||
Cap
|
||||
</div>
|
||||
{ISSUE_THREAD_INTERACTION_KINDS.map((kind) => {
|
||||
const entry = governance[kind] ?? {};
|
||||
const kindLabel = INTERACTION_KIND_LABELS[kind];
|
||||
return (
|
||||
<Fragment key={kind}>
|
||||
<div className="text-sm font-medium sm:font-normal">{kindLabel}</div>
|
||||
<GovernanceSelect
|
||||
testId={`governance-${kind}-default`}
|
||||
ariaLabel={`Default resolver policy for ${kindLabel}`}
|
||||
mobileLabel="Default policy"
|
||||
value={toSelectValue(entry.defaultPolicy)}
|
||||
disabled={governanceMutation.isPending}
|
||||
onChange={(v) => handleGovernanceChange(kind, "defaultPolicy", v)}
|
||||
/>
|
||||
<GovernanceSelect
|
||||
testId={`governance-${kind}-cap`}
|
||||
ariaLabel={`Resolver cap for ${kindLabel}`}
|
||||
mobileLabel="Cap"
|
||||
value={toSelectValue(entry.cap)}
|
||||
disabled={governanceMutation.isPending}
|
||||
onChange={(v) => handleGovernanceChange(kind, "cap", v)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{governanceMutation.isError && (
|
||||
<span className="text-xs text-destructive">
|
||||
{governanceMutation.error instanceof Error
|
||||
? governanceMutation.error.message
|
||||
: "Failed to save interaction governance"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InteractionGovernancePanel
|
||||
governance={governance}
|
||||
onChange={handleGovernanceChange}
|
||||
isPending={governanceMutation.isPending}
|
||||
errorMessage={
|
||||
governanceMutation.isError
|
||||
? governanceMutation.error instanceof Error
|
||||
? governanceMutation.error.message
|
||||
: "Failed to save interaction governance"
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Import / Export */}
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -386,6 +386,63 @@ function PrimeDeskFixtures({
|
|||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** Interaction-shaped item carrying the server's effective resolver audience. */
|
||||
function interactionRow(
|
||||
id: string,
|
||||
title: string,
|
||||
audience: AttentionItem["resolverAudience"],
|
||||
): AttentionItem {
|
||||
return item(id, "issue_thread_interaction", "medium", title, "Confirmation requested on an issue thread.", {
|
||||
inlineResolvable: true,
|
||||
subject: {
|
||||
kind: "interaction",
|
||||
id,
|
||||
companyId,
|
||||
title,
|
||||
identifier: null,
|
||||
status: "pending",
|
||||
href: `/PAP/issues/PAP-1000#interaction-${id}`,
|
||||
metadata: { kind: "request_confirmation", issueId: "issue-1000" },
|
||||
},
|
||||
decisionVerbs: [
|
||||
{ id: "accept", label: "Accept", description: null },
|
||||
{ id: "reject", label: "Reject", description: null },
|
||||
],
|
||||
resolverAudience: audience,
|
||||
});
|
||||
}
|
||||
|
||||
const OPEN_AUDIENCE = {
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
addresseeAgentId: null,
|
||||
addresseeName: null,
|
||||
createdByAgentId: "agent-coder",
|
||||
createdByAgentName: "ClaudeCoder",
|
||||
} as const;
|
||||
|
||||
const INTERACTION_AUDIENCE_ITEMS: AttentionItem[] = [
|
||||
interactionRow("interaction-open", "Close the stale watchdog confirmation?", { ...OPEN_AUDIENCE }),
|
||||
interactionRow("interaction-addressed", "Confirm the migration renumber", {
|
||||
...OPEN_AUDIENCE,
|
||||
addresseeAgentId: "agent-qa",
|
||||
addresseeName: "QA",
|
||||
}),
|
||||
interactionRow("interaction-capped", "Approve the destructive cleanup", {
|
||||
...OPEN_AUDIENCE,
|
||||
effectiveResolverPolicy: "human_only",
|
||||
effectiveResolverPolicySource: "company_cap",
|
||||
}),
|
||||
interactionRow("interaction-legacy", "Confirm the pre-migration rollout", {
|
||||
...OPEN_AUDIENCE,
|
||||
requestedResolverPolicy: "not_creator",
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
resolverPolicyProvenance: "legacy_inherited_restriction",
|
||||
}),
|
||||
];
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Pages/Decisions Desk",
|
||||
parameters: { layout: "fullscreen" },
|
||||
|
|
@ -482,6 +539,33 @@ export const QueuePage: Story = {
|
|||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapsed interaction rows, one per resolver audience (PAP-17287). Each row
|
||||
* states who the *server* will let respond before its compact Accept/Reject —
|
||||
* open default, a named addressee, a company cap to human-only, and a
|
||||
* pre-migration card that stays creator-excluded. Check at 1440×900 and 390×844:
|
||||
* the audience must stay legible above the verbs at both widths.
|
||||
*/
|
||||
export const CollapsedInteractionAudience: Story = {
|
||||
render: () => (
|
||||
<PrimeDeskFixtures>
|
||||
<div className="max-w-3xl space-y-2 p-6">
|
||||
{INTERACTION_AUDIENCE_ITEMS.map((row) => (
|
||||
<AttentionQueueRow
|
||||
key={row.id}
|
||||
item={row}
|
||||
companyId={companyId}
|
||||
agents={AGENTS}
|
||||
expanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PrimeDeskFixtures>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen 3 — the per-card triage strip. A single card, expanded, showing the
|
||||
* decide-by control (with "set by Prioritizer"), queue chips + picker, snooze
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ import {
|
|||
failedRequestConfirmationInteraction,
|
||||
failedToolActionInteraction,
|
||||
genericPendingRequestConfirmationInteraction,
|
||||
agentAddressedRequestConfirmationInteraction,
|
||||
companyCappedRequestConfirmationInteraction,
|
||||
humanOnlyRequestConfirmationInteraction,
|
||||
legacyRestrictedRequestConfirmationInteraction,
|
||||
notCreatorRequestConfirmationInteraction,
|
||||
pendingToolActionDestructiveInteraction,
|
||||
pendingToolActionWriteInteraction,
|
||||
runningToolActionInteraction,
|
||||
|
|
@ -113,6 +118,19 @@ function ScenarioCard({
|
|||
);
|
||||
}
|
||||
|
||||
function AudienceCard({ interaction }: { interaction: RequestConfirmationInteraction }) {
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={() => undefined}
|
||||
onRejectInteraction={() => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InteractiveSuggestedTasksCard() {
|
||||
const [interaction, setInteraction] = useState<SuggestTasksInteraction>(
|
||||
pendingSuggestedTasksInteraction,
|
||||
|
|
@ -784,6 +802,58 @@ export const ToolActionLegacyGeneric: Story = {
|
|||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* PAP-17280: the audience row every pending card now carries. `Anyone` is the
|
||||
* default a requester gets by omitting `resolverPolicy`; the rest are the
|
||||
* narrowings a requester, a company cap, or a governed action asks for.
|
||||
*/
|
||||
export const ResolverAudienceStates: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<Section eyebrow="Resolver audience" title="Who can respond (PAP-17280)">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard
|
||||
title="Default · Anyone"
|
||||
description="No resolverPolicy was requested, so the card is open company attention — the board or any agent, including the one that asked."
|
||||
>
|
||||
<AudienceCard interaction={pendingRequestConfirmationInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Anyone except creator"
|
||||
description="Requested on purpose when the answer has to be independent of the agent that asked."
|
||||
>
|
||||
<AudienceCard interaction={notCreatorRequestConfirmationInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Human only"
|
||||
description="Reserved for a person: agents are turned away by the server, and the copy says so."
|
||||
>
|
||||
<AudienceCard interaction={humanOnlyRequestConfirmationInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Named addressee"
|
||||
description="One agent owns the response; the card stays out of the open attention feed."
|
||||
>
|
||||
<AudienceCard interaction={agentAddressedRequestConfirmationInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Narrowed by a company cap"
|
||||
description="The request asked for Anyone; company interaction governance capped the kind, and the card explains the narrowing."
|
||||
>
|
||||
<AudienceCard interaction={companyCappedRequestConfirmationInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard
|
||||
title="Legacy restricted card"
|
||||
description="Created before Anyone became the default. Migration keeps it restricted fail-closed and the card says a new card would be open."
|
||||
>
|
||||
<AudienceCard interaction={legacyRestrictedRequestConfirmationInteraction} />
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionAllStates: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
|
|
|
|||
Loading…
Reference in New Issue