fix(issues): keep agent mentions as context and defer personal app authorization (#14577)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Each task has one assignee. Explicit assignment and review requests
select who should act.
> - An agent mention started another agent on a task it did not own.
Native attachment staging then rejected that run.
> - Allowing that run through startup could also let two agents work on
the same task.
> - Mentions should identify relevant context. They should not start
work or forward comments to other tasks.
> - A personal app installed on a shared agent must also wait until tool
use to resolve the current user's grant.
> - This pull request removes mention dispatch and keeps missing
personal app credentials from blocking startup.

## Linked Issues or Issue Description

**What happened?**

A native agent mentioned on another agent's task failed with
`paperclip_runner_attachment_staging_not_authorized`. The source task
could already be complete. A nearby optional-app warning was a separate
problem: personal app tools were excluded when their shared health state
required attention.

**Expected behavior**

An agent mention is context only. It does not wake the agent, take
ownership, or copy a comment onto another task. Normal feedback still
reaches the assignee. Assignment and explicit review requests still
dispatch work. An unavailable personal app does not block startup or
produce a startup warning. Tool use requests the current user's
authorization and never uses another user's grant.

**Steps to reproduce**

1. Assign a task to agent A. Post a comment that mentions agent B,
including a comment that closes A's task or references B's child task.
2. Confirm the comment retains its agent link and B receives no run or
deferred wake. A can still receive normal feedback.
3. Install an active personal MCP connection on B. Give only Alice a
grant and leave shared health at `error`.
4. Explicitly assign work to B for another user. Confirm it can finish
without using the app.
5. Ask B to use the app. Confirm its tool call shows an inline
connection request for the current user.

Related work: Refs #11144. This change uses the existing execution-time
personal grant resolution.

## What Changed

- Remove mention dispatch from standalone comments and issue updates.
Remove implicit forwarding of parent comments to a mentioned worker's
child task.
- Ignore new requests with the legacy mention wake reason before
creating a run or deferred request. Preserve already accepted queue
entries, which can combine assignments and feedback with a later
mention.
- Remove the native mention admission, staging, and finalization
exceptions from this PR. Native task ownership checks remain intact.
- Keep active, installed personal app tools available despite shared
health errors. Remove optional-app startup warnings. Tool execution
retains the current user's grant and policy checks.
- Update agent instructions and product/API docs. Refresh generated
capability source anchors.

## Verification

- Red: comment-route regressions reproduced extra agent wakes and child
comment forwarding. A separate regression proved that cancelling by the
last coalesced reason could drop an accepted assignment.
- Green: the targeted route, wake queue, heartbeat, workspace,
responsible-user, MCP discovery, and HTTP gateway suites passed. The
final queue and heartbeat rerun passed 104 tests, the restored queue
adapter passed 56, and both comment-route suites passed 135. These
include accepted assignment preservation, rejection of new mention
requests, and normal assignee feedback.
- `pnpm -r typecheck` and `pnpm build` passed locally. The full local
`pnpm test:run` attempt was interrupted for review/CI fixes, so it is
not claimed as a completed local pass. It exposed a cleanup timing race
in the concurrent-mention assertion, now fixed and verified across 10
repetitions. CI also exposed an obsolete test waiting for the removed
mention lookup; it was reproduced and fixed, then both comment suites
passed. Final full-suite verification is through CI.
- Final head `bd9ea4cb05a8f081c54e017760a8999f9ea6ef44`: 54 checks
passed, 2 Storybook checks intentionally skipped; no pending or failing
checks. Full CI includes general and serialized suites, all 8 browser
shards, runner verification, typecheck, build, and canary dry run.
Greptile is 5/5 on this exact commit, with no unresolved findings.
- One unchanged Cursor adapter test hit its 10-second CI timeout. All 5
tests in that file passed locally; one retry of its CI shard passed all
674 tests (3 skipped). The aggregate verification gate then passed. No
code or timeout was changed for that retry.
- Live browser check: inserted a structured mention with the picker on a
human-owned task. The saved link remained visible. Database checks found
zero new runs and zero wake requests.
- Live Codex runner check: explicitly assigned that task with the
unavailable personal app attached. The run succeeded and committed
completion without using the app or creating a connection card.
- Live browser follow-up: asked the assignee to call PostHog and
mentioned another enabled agent as context. Only the assignee ran. It
succeeded and displayed the existing inline connection card. Only
Alice's grant existed; the run belonged to a different user.
- The HTTP regression covers tool discovery with no provider calls or
connection cards, first use returning the current user's authorization
request, and successful retry after that user's grant exists.
- App checks use an isolated local fixture and a fake MCP provider. They
do not use production app credentials.

## Risks

- Intentional behavior change: workflows that used mentions to wake
agents must use assignment, a bounded child task, or an explicit review
request.
- Already accepted queue entries retain their prior rules. An old entry
can combine assignment or feedback with a later mention; its last reason
cannot safely identify mention-only work. New mention requests create no
run or deferred wake.
- Personal apps with a shared health error remain discoverable. Actual
tool use still requires the responsible user's grant and existing policy
gates.
- No database migration or public API schema change.

## Model Used

- OpenAI GPT-6 through Codex, with reasoning, repository tools, code
execution, and browser testing. The exact serving model ID and
context-window size are not exposed in this session.
- Live native-run verification used `gpt-6-astra` through the Codex
provider.

## 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 (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-29 12:49:01 -05:00
1 parent e5bf9d49a5
commit 2de43fc909
24 files changed
+417 -829

No files matched your search

+5
View File
@@ -622,6 +622,11 @@ 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 @-mentions are context links only: they do not wake the mentioned agent,
assign work, or forward comments to another task. Normal comment feedback still
routes to the current assignee. Work for another agent requires explicit
assignment, delegation, or a review request.
Agent-authored issue comments persist the responsible user derived from the
authenticated actor; clients cannot choose that attribution. Each comment also
records the write-policy reason, and spoof attempts fail with an audited 422.
+2
View File
@@ -415,6 +415,8 @@ Tasks use **single assignment** (one agent per task) with **atomic checkout**:
No optimistic locking or CRDTs needed. The single-assignment model + atomic checkout prevents conflicts at the design level.
Agent @-mentions provide context without waking agents or changing task ownership. New work requires explicit assignment, delegation, or a review request; ordinary issue comments can still wake the current assignee.
Releasing a terminal task clears execution locks while preserving its assigned
owner and final status. Assignment remains part of the work history after Done
or Cancelled. Releasing unfinished work still relinquishes the agent assignment;
+17
View File
@@ -62,6 +62,23 @@ back that step while retaining the established draft identity.
Later health checks still require the user's authorization and return an
actionable `422` error when it is missing.
### Personal apps on shared agents
Installing a personal app on an agent does not require every user who runs that
agent to connect the app. Runs start without probing optional apps or warning
about another user's missing credentials. The installed app's cached tools stay
available even when its shared health check needs attention.
Authorization happens when the agent calls an app tool. Paperclip uses the run's
responsible user, never another user's personal grant. If that user has not
connected the app, the tool returns `user_authorization_required` and adds an
inline connection request. Unrelated work can continue without using the app.
Disabled or uninstalled apps remain unavailable.
Optional assigned apps do not emit run-start connection warnings, including
unavailable shared apps. Their health state and reconnect controls remain in
Apps. An unrelated run does not need to act on that state.
### Slack app access
If Slack reports that MCP access is disabled for the app, ask the Slack app
+2 -10
View File
@@ -394,7 +394,6 @@ Issue document comments, document annotation comments, and document review comme
Document-scoped activity may still route work when it is converted into an explicit action-path primitive. Valid routing exceptions include:
- an issue mention or structured agent mention that intentionally wakes or assigns a named participant
- a document-review assignment that names a reviewer or assignee for the review state
- a response to an issue-thread interaction, such as `request_confirmation`, `ask_user_questions`, or `suggest_tasks`
- intentional board routing that assigns or reassigns the issue, opens a first-class blocker, creates delegated follow-up work, or queues a typed wake
@@ -423,16 +422,9 @@ A wake is the delivery path for a selected agent owner. If an interrupting updat
If the committed update assigns the issue to a user, clears the agent assignee, or leaves the issue without an agent owner, Paperclip must not imply that an agent handoff happened. The issue is then waiting on the human owner or on a future explicit assignment, blocker, approval, interaction, monitor, or recovery action.
Plain text is not assignment. Writing an agent's name, role, or team label in a comment does not change ownership and does not create an agent wake. Agent routing from comment text requires a structured agent mention that resolves inside the company, an explicit `assigneeAgentId` mutation, or an existing current agent assignee receiving normal issue-thread feedback.
Agent names and structured @-mentions are context, not assignment or wake requests. Mentions remain on the original comment and do not start an agent, authorize checkout, or copy feedback onto a referenced child task. This applies equally to board and agent comments, standalone comments and issue updates, and open and terminal tasks. New work for another agent requires explicit assignment, a bounded delegated task, or an explicit review request.
A delegation comment from the current assignee's run on this parent must not start competing parent work when the named worker already owns the referenced child. This applies to issue updates with a comment and standalone comments. Verify the source run's company, agent, and parent-task context. Then verify that the comment references the child's identifier, the child's `parentId` names this parent, and the child belongs to the same company and is assigned to the mentioned worker. Apply child-aware routing only in either of these states:
- The parent is `blocked` and the child is `in_progress`. The child must have a blocker edge to the parent. The child's execution or checkout run must still be `running`, belong to that worker and company, and name that child in its run context. Verify comment and mutation access to the child, then retain the parent comment and append a linked copy on the child. Preserve the full comment, author, source run, responsible-user attribution, and source trust. Target the normal mention wake at the child and its new comment ID, with explicit resume and follow-up intent. This keeps new feedback available to the worker and lets the existing queue serialize a child continuation behind its current execution.
- The parent and child are both `done`. The assignee's closing comment must not start another worker run for the completed delegation. A blocker edge is not required after completion: a fast child can finish before the lead needs to record a wait. New agent work must use explicit `resume: true`, a status change, or a new assigned task. Explicit resume moves the parent out of `done` before this rule runs; the comment's prose alone does not restart completed work.
The completed-delegation comment remains on the parent without a worker wake. Neither path changes ownership. Board-user comments and unrelated mentions retain their normal wake behavior. If multiple referenced children qualify for the same worker, the child or run no longer meets these conditions, child comment or mutation access is denied, or a lookup or copy fails, use the normal parent mention path. Do not parse mentions again while copying a comment, which would create another routing loop. Completion of the child still uses the existing blocker-resolution wake for the parent's assignee.
The parent may receive a closing comment before its assignee changes the status to `done`. Recheck the completed-delegation rule when releasing that parent execution, before promoting a deferred mention. On the same transaction, verify the final parent state, finishing run, and every original queued or deferred comment ID. Each comment must belong to this parent and company, come from its assignee's finishing run, and reference exactly one completed direct child assigned to the mentioned worker. A link to the parent itself is allowed; any other extra issue reference keeps the normal mention path, including an unknown or foreign reference. Mixed human, other-run, unrelated, or ambiguous input retains its normal wake path. Explicit continuation and interaction requests also retain their normal path.
Normal issue-thread feedback can still wake the current assignee according to the comment policy; self-comments and closed-task comments retain their existing suppression rules. Explicit assignment, review, interaction, and blocker-resolution wakes retain their own routing. New mention wake requests are ignored at admission, including calls from legacy producers. Already accepted queue entries retain their existing execution rules: an old entry can combine an assignment or other feedback with a later mention, so its last wake reason does not prove that the entry is mention-only.
Accepted agent feedback must survive a child changing to `done` before its active run exits. Deferred wake promotion may reopen that completed child only for its current assignee, with explicit agent resume intent and live tracked comments from another author. Claim promotion before reopening. Cancelled tasks, deleted comments, self-authored comments, empty continuations, and agent continuations without explicit intent keep their existing suppression rules. Normal pause, ownership, authorization, and budget gates still apply.
+1 -1
View File
@@ -175,7 +175,7 @@ POST /api/issues/{issueId}/comments
{ "body": "Progress update in markdown..." }
```
@-mentions (`@AgentName`) in comments trigger heartbeats for the mentioned agent.
Agent @-mentions are context only and do not trigger heartbeats. Normal comment feedback can still wake the current assignee. Use explicit assignment or a review request to ask another agent to act.
## Issue-Thread Interactions
@@ -41,22 +41,16 @@ Submitted CTO hire request and linked it for board review.
## @-Mentions
Mention another agent by name using `@AgentName` in a comment to wake them:
Use a structured agent link to identify someone relevant to the task:
```
POST /api/issues/{issueId}/comments
{ "body": "@EngineeringLead I need a review on this implementation." }
{ "body": "[@Engineering Lead](agent://agent-id) has relevant context on this implementation." }
```
The name must match the agent's `name` field exactly (case-insensitive). This triggers a heartbeat for the mentioned agent.
Resolve the agent ID from the company’s agent list. Structured mentions also work inside the `comment` field of `PATCH /api/issues/{issueId}`.
@-mentions also work inside the `comment` field of `PATCH /api/issues/{issueId}`.
## @-Mention Rules
- **Don't overuse mentions** — each mention triggers a budget-consuming heartbeat
- **Don't use mentions for assignment** — create/assign a task instead
- **Mention handoff exception** — if an agent is explicitly @-mentioned with a clear directive to take a task, they may self-assign via checkout
Mentions are context only. They never wake the mentioned agent, assign work, forward comments, or authorize self-assignment. Normal feedback can still wake the current assignee. To request work from another agent, assign a task, create a bounded child task, or request an explicit review.
## Structured Decisions
+1 -1
View File
@@ -72,7 +72,7 @@ paperclip/
When a heartbeat fires:
1. **Trigger** — Scheduler, manual invoke, or event (assignment, mention) triggers a heartbeat
1. **Trigger** — Scheduler, manual invoke, or event (assignment, assignee feedback) triggers a heartbeat
2. **Adapter invocation** — Server calls the configured adapter's `execute()` function
3. **Agent process** — Adapter spawns the agent (e.g. Claude Code CLI) with Paperclip env vars and a prompt
4. **Agent work** — The agent calls Paperclip's REST API to check assignments, checkout tasks, do work, and update status
@@ -165,37 +165,37 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/references/api-reference.md:7-dashboard-for-health-check:626 | optional_agent_tool | skills/paperclip/references/api-reference.md:626 |
| skill:skills/paperclip/references/api-reference.md:comments-and-mentions:632 | always_agent_tool | skills/paperclip/references/api-reference.md:632 |
| skill:skills/paperclip/references/api-reference.md:update:639 | optional_agent_tool | skills/paperclip/references/api-reference.md:639 |
| skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:677 | optional_agent_tool | skills/paperclip/references/api-reference.md:677 |
| skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:681 | optional_agent_tool | skills/paperclip/references/api-reference.md:681 |
| skill:skills/paperclip/references/api-reference.md:questions-and-dependencies:691 | always_agent_tool | skills/paperclip/references/api-reference.md:691 |
| skill:skills/paperclip/references/api-reference.md:company-context:703 | optional_agent_tool | skills/paperclip/references/api-reference.md:703 |
| skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:715 | optional_agent_tool | skills/paperclip/references/api-reference.md:715 |
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:735 | optional_agent_tool | skills/paperclip/references/api-reference.md:735 |
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:754 | optional_agent_tool | skills/paperclip/references/api-reference.md:754 |
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:787 | optional_agent_tool | skills/paperclip/references/api-reference.md:787 |
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:811 | optional_agent_tool | skills/paperclip/references/api-reference.md:811 |
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:830 | optional_agent_tool | skills/paperclip/references/api-reference.md:830 |
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:859 | optional_agent_tool | skills/paperclip/references/api-reference.md:859 |
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:863 | optional_agent_tool | skills/paperclip/references/api-reference.md:863 |
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:928 | optional_agent_tool | skills/paperclip/references/api-reference.md:928 |
| skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:937 | always_agent_tool | skills/paperclip/references/api-reference.md:937 |
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1034 | always_agent_tool | skills/paperclip/references/api-reference.md:1034 |
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1092 | always_agent_tool | skills/paperclip/references/api-reference.md:1092 |
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1207 | optional_agent_tool | skills/paperclip/references/api-reference.md:1207 |
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1317 | optional_agent_tool | skills/paperclip/references/api-reference.md:1317 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1323 | always_agent_tool | skills/paperclip/references/api-reference.md:1323 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1341 | always_agent_tool | skills/paperclip/references/api-reference.md:1341 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1371 | control_plane_owned | skills/paperclip/references/api-reference.md:1371 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1385 | optional_agent_tool | skills/paperclip/references/api-reference.md:1385 |
| skill:skills/paperclip/references/api-reference.md:agents:1387 | optional_agent_tool | skills/paperclip/references/api-reference.md:1387 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1408 | optional_agent_tool | skills/paperclip/references/api-reference.md:1408 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1448 | optional_agent_tool | skills/paperclip/references/api-reference.md:1448 |
| skill:skills/paperclip/references/api-reference.md:routines:1472 | optional_agent_tool | skills/paperclip/references/api-reference.md:1472 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1488 | optional_agent_tool | skills/paperclip/references/api-reference.md:1488 |
| skill:skills/paperclip/references/api-reference.md:secrets:1510 | optional_agent_tool | skills/paperclip/references/api-reference.md:1510 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1523 | optional_agent_tool | skills/paperclip/references/api-reference.md:1523 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1623 | optional_agent_tool | skills/paperclip/references/api-reference.md:1623 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1663 | optional_agent_tool | skills/paperclip/references/api-reference.md:1663 |
| skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:661 | optional_agent_tool | skills/paperclip/references/api-reference.md:661 |
| skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:665 | optional_agent_tool | skills/paperclip/references/api-reference.md:665 |
| skill:skills/paperclip/references/api-reference.md:questions-and-dependencies:675 | always_agent_tool | skills/paperclip/references/api-reference.md:675 |
| skill:skills/paperclip/references/api-reference.md:company-context:687 | optional_agent_tool | skills/paperclip/references/api-reference.md:687 |
| skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:699 | optional_agent_tool | skills/paperclip/references/api-reference.md:699 |
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:719 | optional_agent_tool | skills/paperclip/references/api-reference.md:719 |
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:738 | optional_agent_tool | skills/paperclip/references/api-reference.md:738 |
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:771 | optional_agent_tool | skills/paperclip/references/api-reference.md:771 |
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:795 | optional_agent_tool | skills/paperclip/references/api-reference.md:795 |
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:814 | optional_agent_tool | skills/paperclip/references/api-reference.md:814 |
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:843 | optional_agent_tool | skills/paperclip/references/api-reference.md:843 |
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:847 | optional_agent_tool | skills/paperclip/references/api-reference.md:847 |
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:912 | optional_agent_tool | skills/paperclip/references/api-reference.md:912 |
| skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:921 | always_agent_tool | skills/paperclip/references/api-reference.md:921 |
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1018 | always_agent_tool | skills/paperclip/references/api-reference.md:1018 |
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1076 | always_agent_tool | skills/paperclip/references/api-reference.md:1076 |
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1191 | optional_agent_tool | skills/paperclip/references/api-reference.md:1191 |
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1301 | optional_agent_tool | skills/paperclip/references/api-reference.md:1301 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1307 | always_agent_tool | skills/paperclip/references/api-reference.md:1307 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1325 | always_agent_tool | skills/paperclip/references/api-reference.md:1325 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1355 | control_plane_owned | skills/paperclip/references/api-reference.md:1355 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1369 | optional_agent_tool | skills/paperclip/references/api-reference.md:1369 |
| skill:skills/paperclip/references/api-reference.md:agents:1371 | optional_agent_tool | skills/paperclip/references/api-reference.md:1371 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1392 | optional_agent_tool | skills/paperclip/references/api-reference.md:1392 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1432 | optional_agent_tool | skills/paperclip/references/api-reference.md:1432 |
| skill:skills/paperclip/references/api-reference.md:routines:1456 | optional_agent_tool | skills/paperclip/references/api-reference.md:1456 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1472 | optional_agent_tool | skills/paperclip/references/api-reference.md:1472 |
| skill:skills/paperclip/references/api-reference.md:secrets:1494 | optional_agent_tool | skills/paperclip/references/api-reference.md:1494 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1507 | optional_agent_tool | skills/paperclip/references/api-reference.md:1507 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1607 | optional_agent_tool | skills/paperclip/references/api-reference.md:1607 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1647 | optional_agent_tool | skills/paperclip/references/api-reference.md:1647 |
## Legacy MCP Alias Index
@@ -614,288 +614,288 @@
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:677",
"id": "skill:skills/paperclip/references/api-reference.md:661",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L677:cross-team-work-and-delegation",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L661:cross-team-work-and-delegation",
"heading": "Cross-Team Work and Delegation",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:681",
"id": "skill:skills/paperclip/references/api-reference.md:665",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L681:receiving-cross-team-work",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L665:receiving-cross-team-work",
"heading": "Receiving cross-team work",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:691",
"id": "skill:skills/paperclip/references/api-reference.md:675",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L691:questions-and-dependencies",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L675:questions-and-dependencies",
"heading": "Questions and dependencies",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "request_human_input",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:703",
"id": "skill:skills/paperclip/references/api-reference.md:687",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L703:company-context",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L687:company-context",
"heading": "Company Context",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:715",
"id": "skill:skills/paperclip/references/api-reference.md:699",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L715:company-branding-ceo-board",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L699:company-branding-ceo-board",
"heading": "Company Branding (CEO / Board)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:735",
"id": "skill:skills/paperclip/references/api-reference.md:719",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L735:openclaw-invite-prompt-ceo",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L719:openclaw-invite-prompt-ceo",
"heading": "OpenClaw Invite Prompt (CEO)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:754",
"id": "skill:skills/paperclip/references/api-reference.md:738",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L754:setting-agent-instructions-path",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L738:setting-agent-instructions-path",
"heading": "Setting Agent Instructions Path",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:787",
"id": "skill:skills/paperclip/references/api-reference.md:771",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L787:project-setup-create-workspace",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L771:project-setup-create-workspace",
"heading": "Project Setup (Create + Workspace)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:811",
"id": "skill:skills/paperclip/references/api-reference.md:795",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L811:option-a-one-call-create-with-workspace",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L795:option-a-one-call-create-with-workspace",
"heading": "Option A: One-call create with workspace",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:830",
"id": "skill:skills/paperclip/references/api-reference.md:814",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L830:option-b-two-calls-project-first-then-workspace",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L814:option-b-two-calls-project-first-then-workspace",
"heading": "Option B: Two calls (project first, then workspace)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:859",
"id": "skill:skills/paperclip/references/api-reference.md:843",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L859:governance-and-approvals",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L843:governance-and-approvals",
"heading": "Governance and Approvals",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:863",
"id": "skill:skills/paperclip/references/api-reference.md:847",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L863:requesting-a-hire-management-only",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L847:requesting-a-hire-management-only",
"heading": "Requesting a hire (management only)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:928",
"id": "skill:skills/paperclip/references/api-reference.md:912",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L928:ceo-strategy-approval",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L912:ceo-strategy-approval",
"heading": "CEO strategy approval",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:937",
"id": "skill:skills/paperclip/references/api-reference.md:921",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L937:questions-and-waiting-for-human-input",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L921:questions-and-waiting-for-human-input",
"heading": "Questions and waiting for human input",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "request_human_input",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1034",
"id": "skill:skills/paperclip/references/api-reference.md:1018",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1034:issue-thread-confirmations",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1018:issue-thread-confirmations",
"heading": "Issue-thread confirmations",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1092",
"id": "skill:skills/paperclip/references/api-reference.md:1076",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1092:checkbox-confirmations",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1076:checkbox-confirmations",
"heading": "Checkbox confirmations",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1207",
"id": "skill:skills/paperclip/references/api-reference.md:1191",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1207:item-verdict-requests",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1191:item-verdict-requests",
"heading": "Item verdict requests",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1317",
"id": "skill:skills/paperclip/references/api-reference.md:1301",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1317:checking-approval-status",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1301:checking-approval-status",
"heading": "Checking approval status",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1323",
"id": "skill:skills/paperclip/references/api-reference.md:1307",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1323:approval-follow-up-requesting-agent",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1307:approval-follow-up-requesting-agent",
"heading": "Approval follow-up (requesting agent)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1341",
"id": "skill:skills/paperclip/references/api-reference.md:1325",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1341:issue-lifecycle",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1325:issue-lifecycle",
"heading": "Issue Lifecycle",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1355",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1355:error-handling",
"heading": "Error Handling",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1369",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1369:full-api-reference",
"heading": "Full API Reference",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1371",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1371:error-handling",
"heading": "Error Handling",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1385",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1385:full-api-reference",
"heading": "Full API Reference",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1387",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1387:agents",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1371:agents",
"heading": "Agents",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1408",
"id": "skill:skills/paperclip/references/api-reference.md:1392",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1408:issues-tasks",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1392:issues-tasks",
"heading": "Issues (Tasks)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1448",
"id": "skill:skills/paperclip/references/api-reference.md:1432",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1448:companies-projects-goals",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1432:companies-projects-goals",
"heading": "Companies, Projects, Goals",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1456",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1456:routines",
"heading": "Routines",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1472",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1472:routines",
"heading": "Routines",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1488",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1488:approvals-costs-activity-dashboard",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1472:approvals-costs-activity-dashboard",
"heading": "Approvals, Costs, Activity, Dashboard",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1510",
"id": "skill:skills/paperclip/references/api-reference.md:1494",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1510:secrets",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1494:secrets",
"heading": "Secrets",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1523",
"id": "skill:skills/paperclip/references/api-reference.md:1507",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1523:agent-secret-proposals",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1507:agent-secret-proposals",
"heading": "Agent secret proposals",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1576",
"id": "skill:skills/paperclip/references/api-reference.md:1560",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1576:re-bind-an-existing-secret-under-a-new-path-no-secret-id",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1560:re-bind-an-existing-secret-under-a-new-path-no-secret-id",
"heading": "Re-bind an existing secret under a new path (no secret ID)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1623",
"id": "skill:skills/paperclip/references/api-reference.md:1607",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1623:agent-secret-access",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1607:agent-secret-access",
"heading": "Agent secret access",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1663",
"id": "skill:skills/paperclip/references/api-reference.md:1647",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1663:common-mistakes",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1647:common-mistakes",
"heading": "Common Mistakes",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
@@ -5,6 +5,6 @@ Generated by `scripts/generate-capability-contract.mjs`; do not edit generated f
- Skill/reference headings: 156
- Legacy MCP tools: 42
- Eval cases: 106 across 16 groups
- Deterministic content SHA-256: `3a651e198c543b747fb4abb78e6a2b4ece3082bfc9941de5095cccd707d04b13`
- Deterministic content SHA-256: `f361ed9e6cbdc316826c6b19a91b649ed83d63d310917fd68ffa4b74c585f01a`
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
@@ -1874,9 +1874,9 @@
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:677",
"id": "skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:661",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:677",
"sourceAnchor": "skills/paperclip/references/api-reference.md:661",
"title": "Cross-Team Work and Delegation",
"expectedSemantics": "Skill guidance headed “Cross-Team Work and Delegation”.",
"primaryDisposition": "optional_agent_tool",
@@ -1885,13 +1885,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:677"
"skill:skills/paperclip/references/api-reference.md:661"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:681",
"id": "skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:665",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:681",
"sourceAnchor": "skills/paperclip/references/api-reference.md:665",
"title": "Receiving cross-team work",
"expectedSemantics": "Skill guidance headed “Receiving cross-team work”.",
"primaryDisposition": "optional_agent_tool",
@@ -1900,13 +1900,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:681"
"skill:skills/paperclip/references/api-reference.md:665"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:questions-and-dependencies:691",
"id": "skill:skills/paperclip/references/api-reference.md:questions-and-dependencies:675",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:691",
"sourceAnchor": "skills/paperclip/references/api-reference.md:675",
"title": "Questions and dependencies",
"expectedSemantics": "Skill guidance headed “Questions and dependencies”.",
"primaryDisposition": "always_agent_tool",
@@ -1915,13 +1915,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:691"
"skill:skills/paperclip/references/api-reference.md:675"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:company-context:703",
"id": "skill:skills/paperclip/references/api-reference.md:company-context:687",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:703",
"sourceAnchor": "skills/paperclip/references/api-reference.md:687",
"title": "Company Context",
"expectedSemantics": "Skill guidance headed “Company Context”.",
"primaryDisposition": "optional_agent_tool",
@@ -1930,13 +1930,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:703"
"skill:skills/paperclip/references/api-reference.md:687"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:715",
"id": "skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:699",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:715",
"sourceAnchor": "skills/paperclip/references/api-reference.md:699",
"title": "Company Branding (CEO / Board)",
"expectedSemantics": "Skill guidance headed “Company Branding (CEO / Board)”.",
"primaryDisposition": "optional_agent_tool",
@@ -1945,13 +1945,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:715"
"skill:skills/paperclip/references/api-reference.md:699"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:735",
"id": "skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:719",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:735",
"sourceAnchor": "skills/paperclip/references/api-reference.md:719",
"title": "OpenClaw Invite Prompt (CEO)",
"expectedSemantics": "Skill guidance headed “OpenClaw Invite Prompt (CEO)”.",
"primaryDisposition": "optional_agent_tool",
@@ -1960,13 +1960,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:735"
"skill:skills/paperclip/references/api-reference.md:719"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:754",
"id": "skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:738",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:754",
"sourceAnchor": "skills/paperclip/references/api-reference.md:738",
"title": "Setting Agent Instructions Path",
"expectedSemantics": "Skill guidance headed “Setting Agent Instructions Path”.",
"primaryDisposition": "optional_agent_tool",
@@ -1975,13 +1975,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:754"
"skill:skills/paperclip/references/api-reference.md:738"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:787",
"id": "skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:771",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:787",
"sourceAnchor": "skills/paperclip/references/api-reference.md:771",
"title": "Project Setup (Create + Workspace)",
"expectedSemantics": "Skill guidance headed “Project Setup (Create + Workspace)”.",
"primaryDisposition": "optional_agent_tool",
@@ -1990,13 +1990,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:787"
"skill:skills/paperclip/references/api-reference.md:771"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:811",
"id": "skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:795",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:811",
"sourceAnchor": "skills/paperclip/references/api-reference.md:795",
"title": "Option A: One-call create with workspace",
"expectedSemantics": "Skill guidance headed “Option A: One-call create with workspace”.",
"primaryDisposition": "optional_agent_tool",
@@ -2005,13 +2005,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:811"
"skill:skills/paperclip/references/api-reference.md:795"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:830",
"id": "skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:814",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:830",
"sourceAnchor": "skills/paperclip/references/api-reference.md:814",
"title": "Option B: Two calls (project first, then workspace)",
"expectedSemantics": "Skill guidance headed “Option B: Two calls (project first, then workspace)”.",
"primaryDisposition": "optional_agent_tool",
@@ -2020,13 +2020,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:830"
"skill:skills/paperclip/references/api-reference.md:814"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:governance-and-approvals:859",
"id": "skill:skills/paperclip/references/api-reference.md:governance-and-approvals:843",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:859",
"sourceAnchor": "skills/paperclip/references/api-reference.md:843",
"title": "Governance and Approvals",
"expectedSemantics": "Skill guidance headed “Governance and Approvals”.",
"primaryDisposition": "optional_agent_tool",
@@ -2035,13 +2035,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:859"
"skill:skills/paperclip/references/api-reference.md:843"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:863",
"id": "skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:847",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:863",
"sourceAnchor": "skills/paperclip/references/api-reference.md:847",
"title": "Requesting a hire (management only)",
"expectedSemantics": "Skill guidance headed “Requesting a hire (management only)”.",
"primaryDisposition": "optional_agent_tool",
@@ -2050,13 +2050,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:863"
"skill:skills/paperclip/references/api-reference.md:847"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:928",
"id": "skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:912",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:928",
"sourceAnchor": "skills/paperclip/references/api-reference.md:912",
"title": "CEO strategy approval",
"expectedSemantics": "Skill guidance headed “CEO strategy approval”.",
"primaryDisposition": "optional_agent_tool",
@@ -2065,13 +2065,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:928"
"skill:skills/paperclip/references/api-reference.md:912"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:937",
"id": "skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:921",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:937",
"sourceAnchor": "skills/paperclip/references/api-reference.md:921",
"title": "Questions and waiting for human input",
"expectedSemantics": "Skill guidance headed “Questions and waiting for human input”.",
"primaryDisposition": "always_agent_tool",
@@ -2080,13 +2080,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:937"
"skill:skills/paperclip/references/api-reference.md:921"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1034",
"id": "skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1018",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1034",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1018",
"title": "Issue-thread confirmations",
"expectedSemantics": "Skill guidance headed “Issue-thread confirmations”.",
"primaryDisposition": "always_agent_tool",
@@ -2095,13 +2095,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1034"
"skill:skills/paperclip/references/api-reference.md:1018"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1092",
"id": "skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1076",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1092",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1076",
"title": "Checkbox confirmations",
"expectedSemantics": "Skill guidance headed “Checkbox confirmations”.",
"primaryDisposition": "always_agent_tool",
@@ -2110,13 +2110,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1092"
"skill:skills/paperclip/references/api-reference.md:1076"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1207",
"id": "skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1191",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1207",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1191",
"title": "Item verdict requests",
"expectedSemantics": "Skill guidance headed “Item verdict requests”.",
"primaryDisposition": "optional_agent_tool",
@@ -2125,13 +2125,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1207"
"skill:skills/paperclip/references/api-reference.md:1191"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:checking-approval-status:1317",
"id": "skill:skills/paperclip/references/api-reference.md:checking-approval-status:1301",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1317",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1301",
"title": "Checking approval status",
"expectedSemantics": "Skill guidance headed “Checking approval status”.",
"primaryDisposition": "optional_agent_tool",
@@ -2140,13 +2140,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1317"
"skill:skills/paperclip/references/api-reference.md:1301"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1323",
"id": "skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1307",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1323",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1307",
"title": "Approval follow-up (requesting agent)",
"expectedSemantics": "Skill guidance headed “Approval follow-up (requesting agent)”.",
"primaryDisposition": "always_agent_tool",
@@ -2155,13 +2155,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1323"
"skill:skills/paperclip/references/api-reference.md:1307"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1341",
"id": "skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1325",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1341",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1325",
"title": "Issue Lifecycle",
"expectedSemantics": "Skill guidance headed “Issue Lifecycle”.",
"primaryDisposition": "always_agent_tool",
@@ -2170,13 +2170,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1341"
"skill:skills/paperclip/references/api-reference.md:1325"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:error-handling:1371",
"id": "skill:skills/paperclip/references/api-reference.md:error-handling:1355",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1371",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1355",
"title": "Error Handling",
"expectedSemantics": "Skill guidance headed “Error Handling”.",
"primaryDisposition": "control_plane_owned",
@@ -2184,44 +2184,44 @@
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1355"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:full-api-reference:1369",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1369",
"title": "Full API Reference",
"expectedSemantics": "Skill guidance headed “Full API Reference”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1369"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agents:1371",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1371",
"title": "Agents",
"expectedSemantics": "Skill guidance headed “Agents”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1371"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:full-api-reference:1385",
"id": "skill:skills/paperclip/references/api-reference.md:issues-tasks:1392",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1385",
"title": "Full API Reference",
"expectedSemantics": "Skill guidance headed “Full API Reference”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1385"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agents:1387",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1387",
"title": "Agents",
"expectedSemantics": "Skill guidance headed “Agents”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1387"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:issues-tasks:1408",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1408",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1392",
"title": "Issues (Tasks)",
"expectedSemantics": "Skill guidance headed “Issues (Tasks)”.",
"primaryDisposition": "optional_agent_tool",
@@ -2230,13 +2230,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1408"
"skill:skills/paperclip/references/api-reference.md:1392"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1448",
"id": "skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1432",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1448",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1432",
"title": "Companies, Projects, Goals",
"expectedSemantics": "Skill guidance headed “Companies, Projects, Goals”.",
"primaryDisposition": "optional_agent_tool",
@@ -2245,13 +2245,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1448"
"skill:skills/paperclip/references/api-reference.md:1432"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:routines:1472",
"id": "skill:skills/paperclip/references/api-reference.md:routines:1456",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1472",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1456",
"title": "Routines",
"expectedSemantics": "Skill guidance headed “Routines”.",
"primaryDisposition": "optional_agent_tool",
@@ -2259,29 +2259,29 @@
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1456"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1472",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1472",
"title": "Approvals, Costs, Activity, Dashboard",
"expectedSemantics": "Skill guidance headed “Approvals, Costs, Activity, Dashboard”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1472"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1488",
"id": "skill:skills/paperclip/references/api-reference.md:secrets:1494",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1488",
"title": "Approvals, Costs, Activity, Dashboard",
"expectedSemantics": "Skill guidance headed “Approvals, Costs, Activity, Dashboard”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1488"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:secrets:1510",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1510",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1494",
"title": "Secrets",
"expectedSemantics": "Skill guidance headed “Secrets”.",
"primaryDisposition": "optional_agent_tool",
@@ -2290,13 +2290,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1510"
"skill:skills/paperclip/references/api-reference.md:1494"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1523",
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1507",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1523",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1507",
"title": "Agent secret proposals",
"expectedSemantics": "Skill guidance headed “Agent secret proposals”.",
"primaryDisposition": "optional_agent_tool",
@@ -2305,13 +2305,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1523"
"skill:skills/paperclip/references/api-reference.md:1507"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-access:1623",
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-access:1607",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1623",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1607",
"title": "Agent secret access",
"expectedSemantics": "Skill guidance headed “Agent secret access”.",
"primaryDisposition": "optional_agent_tool",
@@ -2320,13 +2320,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1623"
"skill:skills/paperclip/references/api-reference.md:1607"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:common-mistakes:1663",
"id": "skill:skills/paperclip/references/api-reference.md:common-mistakes:1647",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1663",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1647",
"title": "Common Mistakes",
"expectedSemantics": "Skill guidance headed “Common Mistakes”.",
"primaryDisposition": "optional_agent_tool",
@@ -2335,7 +2335,7 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1663"
"skill:skills/paperclip/references/api-reference.md:1647"
]
}
]
@@ -1228,7 +1228,6 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
}, 120_000);
it.each([
{ caseName: "allows a non-assignee mention on completed work", targetAssignee: false, terminalStatus: "done", explicitResume: false },
{ caseName: "delivers explicit agent feedback after completion", targetAssignee: true, terminalStatus: "done", explicitResume: true },
{ caseName: "cancels an assignee continuation without resume intent on completed work", targetAssignee: true, terminalStatus: "done", explicitResume: false },
{ caseName: "cancels an assignee continuation on cancelled work", targetAssignee: true, terminalStatus: "cancelled", explicitResume: true },
@@ -1243,7 +1242,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
const targetAgentId = targetAssignee ? assigneeAgentId : mentionedAgentId;
const shouldReopen = targetAssignee && terminalStatus === "done" && explicitResume;
const commentingAgentId = targetAssignee ? mentionedAgentId : assigneeAgentId;
const wakeReason = targetAssignee ? "issue_commented" : "issue_comment_mentioned";
const wakeReason = "issue_commented";
try {
await db.insert(companies).values({
@@ -3297,7 +3296,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
}
}, 20_000);
it("defers mentioned-agent wakes while another agent is actively executing the same issue", async () => {
it("ignores mentions during another agent run without deferring work", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const primaryAgentId = randomUUID();
@@ -3417,46 +3416,19 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
expect(mentionRun).toBeNull();
await waitFor(async () => {
const deferred = await db
.select()
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, mentionedAgentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
),
)
.then((rows) => rows[0] ?? null);
return Boolean(deferred);
});
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, mentionedAgentId))).toEqual([]);
expect(gateway.getAgentPayloads()).toHaveLength(1);
gateway.releaseFirstWait();
await waitFor(async () => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, mentionedAgentId))
.orderBy(asc(heartbeatRuns.createdAt));
return runs.length === 1 && runs[0]?.status === "succeeded";
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, primaryAgentId));
// Terminal run status is persisted before comment policy and lock
// cleanup. Observe release before checking the final queue state.
const [issue] = await db.select({ executionRunId: issues.executionRunId }).from(issues).where(eq(issues.id, issueId));
return runs.length === 2 && runs.every((run) => run.status === "succeeded") && issue.executionRunId === null;
}, 90_000);
expect(gateway.getAgentPayloads().length).toBeGreaterThanOrEqual(2);
const mentionedRuns = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, mentionedAgentId))
.orderBy(asc(heartbeatRuns.createdAt));
expect(mentionedRuns).toHaveLength(1);
expect(mentionedRuns[0]?.contextSnapshot).toMatchObject({
issueId,
wakeReason: "issue_comment_mentioned",
});
const mentionedRuns = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, mentionedAgentId));
expect(mentionedRuns).toEqual([]);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, mentionedAgentId))).toEqual([]);
const issueAfterMention = await db
.select({
@@ -3469,7 +3441,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
.then((rows) => rows[0] ?? null);
expect(issueAfterMention?.assigneeAgentId).toBe(primaryAgentId);
expect(issueAfterMention?.executionRunId).not.toBe(mentionedRuns[0]?.id);
expect(issueAfterMention?.executionRunId).toBeNull();
expect(issueAfterMention?.executionAgentNameKey).not.toBe(
"mentioned agent",
);
@@ -3504,7 +3476,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
}
}, 120_000);
it("does not mark a direct mentioned-agent run as the issue execution owner", async () => {
it("ignores direct legacy mention wakes without creating a request or executing an adapter", async () => {
const gateway = await createControlledGatewayServer();
const companyId = randomUUID();
const primaryAgentId = randomUUID();
@@ -3605,8 +3577,10 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
requestedByActorId: "user-1",
});
expect(mentionRun).not.toBeNull();
await waitFor(() => gateway.getAgentPayloads().length === 1);
expect(mentionRun).toBeNull();
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, mentionedAgentId))).toEqual([]);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, mentionedAgentId))).toEqual([]);
expect(gateway.getAgentPayloads()).toEqual([]);
const issueDuringMention = await db
.select({
@@ -3624,31 +3598,6 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
executionAgentNameKey: null,
});
gateway.releaseFirstWait();
await waitFor(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, mentionRun!.id))
.then((rows) => rows[0] ?? null);
return run?.status === "succeeded";
}, 90_000);
const issueAfterMention = await db
.select({
assigneeAgentId: issues.assigneeAgentId,
executionRunId: issues.executionRunId,
executionAgentNameKey: issues.executionAgentNameKey,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issueAfterMention).toMatchObject({
assigneeAgentId: primaryAgentId,
executionRunId: null,
executionAgentNameKey: null,
});
} finally {
gateway.releaseFirstWait();
await gateway.close();
@@ -347,7 +347,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
expect(mockAdapterExecute).toHaveBeenCalledTimes(runs.length);
});
it.each(["issue_commented", "issue_comment_mentioned"])(
it.each(["issue_commented"])(
"uses the persisted message author for %s without changing issue ownership",
async (wakeReason) => {
const { companyId, agentId } = await seedCompany();
@@ -178,21 +178,14 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
await db.update(toolConnections)
.set({ healthStatus: "degraded", healthMessage: "fixture unavailable" })
.where(eq(toolConnections.id, installedConnection!.id));
const unavailableReports: Array<Array<{ id: string; name: string }>> = [];
await expect(
buildPaperclipRuntimeMcpServers({
db,
agent: agent!,
runId: randomUUID(),
expectedAssignmentDigest: first[0]!.connectionId.slice("assignment:".length),
onUnavailableAssignedConnections: (connections) => {
unavailableReports.push(connections);
},
}),
).resolves.toEqual([]);
expect(unavailableReports).toEqual([[
{ id: installedConnection!.id, name: installedConnection!.name },
]]);
expect(await db.select().from(toolMcpGatewayTokens)).toHaveLength(2);
await expect(
createManagedMcpRunConfig({
@@ -633,23 +633,19 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => {
expect(executedRunIds).toContain(retryRun!.id);
});
it("defers a non-assignee run and executes its retry despite the assignee mismatch", async () => {
it("preserves a legacy accepted mention run through workspace retry", async () => {
const fixture = await seedWorkspaceFixture();
// A comment-mention wake for an agent that is NOT the issue assignee —
// the interaction-wake shape that legitimately reaches adapter dispatch
// without assignee-ship.
const run = await heartbeat.invoke(
fixture.nonAssigneeAgentId,
"on_demand",
{
issueId: fixture.issueId,
wakeReason: "issue_comment_mentioned",
commentId: randomUUID(),
// Existing queued work can contain coalesced assignment or feedback.
// Only new mention requests are ignored; keep accepted work's rules.
const [run] = await db.insert(heartbeatRuns).values({
companyId: fixture.companyId, agentId: fixture.nonAssigneeAgentId,
invocationSource: "on_demand", status: "queued", responsibleUserId: "responsible-user",
contextSnapshot: {
issueId: fixture.issueId, wakeReason: "issue_comment_mentioned", commentId: randomUUID(),
},
"system",
);
expect(run).not.toBeNull();
}).returning();
await heartbeat.resumeQueuedRuns();
const deferred = await waitForRunToLeaveActiveStates(run!.id);
expect(deferred?.status).toBe("cancelled");
@@ -2405,15 +2405,11 @@ describe.sequential("issue comment reopen routes", () => {
);
mockHeartbeatService.wakeup.mockClear();
mockIssueService.findMentionedAgents.mockClear();
res = await request(await installActor(createApp(), agentActor(agentB)))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "B replies on B's own issue" });
expect(res.status).toBe(201);
await vi.waitFor(() =>
expect(mockIssueService.findMentionedAgents).toHaveBeenCalledOnce(),
);
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
mockIssueService.getById.mockResolvedValue({
@@ -558,10 +558,7 @@ describe("issue update comment wakeups", () => {
eventMessage: "run interrupted by board comment",
}),
);
await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledWith(
existing.companyId,
"stop here, I will take it",
));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
@@ -725,154 +722,63 @@ describe("issue update comment wakeups", () => {
.send({ body: "Plan ready for review." });
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled());
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("still wakes a different mentioned agent from a run-authenticated comment", async () => {
it.each((["post", "patch"] as const).flatMap((method) =>
["assignee_comment", "board_comment", "human_owned", "unassigned", "blocked_parent", "done_parent", "done_comment", ...(method === "patch" ? ["closing_comment"] : [])].map((scenario) => ({ method, scenario })),
))("keeps $method mentions as context for $scenario", async ({ method, scenario }) => {
const selfComment = ["assignee_comment", "blocked_parent", "done_parent", "done_comment", "closing_comment"].includes(scenario);
const existing = makeIssue({
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
status: "in_progress",
assigneeAgentId: ["human_owned", "unassigned"].includes(scenario) ? null : ASSIGNEE_AGENT_ID,
assigneeUserId: scenario === "human_owned" ? "local-board" : null,
status: scenario === "blocked_parent" ? "blocked" : ["done_parent", "done_comment"].includes(scenario) ? "done" : "in_progress",
executionRunId: selfComment ? SOURCE_RUN_ID : null,
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.addComment.mockResolvedValue({
id: "comment-self-cross-mention",
issueId: existing.id,
companyId: existing.companyId,
body: "[@QA](/agents/33333333-3333-4333-8333-333333333333) please verify.",
createdByRunId: SOURCE_RUN_ID,
});
mockIssueService.findMentionedAgents.mockResolvedValue([
MENTIONED_AGENT_ID,
]);
mockHeartbeatService.getRun.mockResolvedValue({
id: SOURCE_RUN_ID,
companyId: existing.companyId,
agentId: ASSIGNEE_AGENT_ID,
status: "running",
});
const res = await request(await createApp())
.post(`/api/issues/${existing.id}/comments`)
.set("X-Paperclip-Run-Id", SOURCE_RUN_ID)
.send({
body: "[@QA](/agents/33333333-3333-4333-8333-333333333333) please verify.",
});
expect(res.status).toBe(201);
await vi.waitFor(() =>
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
MENTIONED_AGENT_ID,
expect.objectContaining({
reason: "issue_comment_mentioned",
}),
);
});
it.each((["post", "patch"] as const).flatMap((method) => [
"active_delegation", "completed_delegation", "human_comment", "completed_human_comment", "unrelated_comment", "completed_child",
"active_feedback", "ambiguous_delegation", "child_access_denied", "child_mutation_denied", "forwarding_failure",
"source_run_other_issue", "completed_source_run_other_issue",
"completed_explicit_resume", "completed_parent_reference", "completed_mixed_reference",
"completed_delegation_without_blocker", "completed_foreign_company", "completed_unrelated_child", "completed_other_assignee", "completed_lookup_failure",
"unrelated_child", "foreign_company", "stopped_run", "foreign_run", "unrelated_run", "lookup_failure",
].map((scenario) => ({ method, scenario }))))("routes $method mentions correctly for $scenario", async ({ method, scenario }) => {
const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null, status: "blocked", executionRunId: SOURCE_RUN_ID });
const child = makeIssue({
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", identifier: "PAP-1000",
parentId: existing.id, assigneeAgentId: MENTIONED_AGENT_ID, assigneeUserId: null,
status: "in_progress", executionRunId: "55555555-5555-4555-8555-555555555555",
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", identifier: "PAP-1000", parentId: existing.id,
assigneeAgentId: MENTIONED_AGENT_ID, assigneeUserId: null,
status: scenario === "done_parent" ? "done" : "in_progress",
executionRunId: "55555555-5555-4555-8555-555555555555",
});
const secondChild = { ...child, id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", identifier: "PAP-1001", executionRunId: "66666666-6666-4666-8666-666666666666" };
const delegationBody = scenario === "completed_parent_reference"
? `${existing.identifier} is done thanks to [@QA](agent://${MENTIONED_AGENT_ID}) completing ${child.identifier}.`
: scenario === "completed_mixed_reference"
? `[@QA](agent://${MENTIONED_AGENT_ID}) completed ${child.identifier}; now investigate PAP-9999.`
: scenario === "active_feedback"
? `${child.identifier} is still failing; [@QA](agent://${MENTIONED_AGENT_ID}) please investigate the new error.`
: scenario === "unrelated_comment"
? `[@QA](agent://${MENTIONED_AGENT_ID}) please review the parent task separately.`
: `Delegated to [@QA](agent://${MENTIONED_AGENT_ID}) via [${child.identifier}](/PAP/issues/${child.identifier}). Waiting for that task to finish.`;
// This unrelated mention must wake after the worker's routing decision.
// It gives the test an observable completion point for the async loop.
const body = `${delegationBody}${scenario === "ambiguous_delegation" ? ` Also check ${secondChild.identifier}.` : ""} [@Observer](agent://${PREVIOUS_AGENT_ID}) please review the parent separately.`;
const completedDelegation = scenario.startsWith("completed_") && scenario !== "completed_child";
const humanComment = scenario === "human_comment" || scenario === "completed_human_comment";
if (scenario === "completed_child" || completedDelegation) child.status = "done";
if (completedDelegation) existing.status = "done";
if (scenario === "unrelated_child" || scenario === "completed_unrelated_child") child.parentId = "other-parent";
if (scenario === "foreign_company" || scenario === "completed_foreign_company") child.companyId = "other-company";
if (scenario === "completed_other_assignee") child.assigneeAgentId = "other-agent";
mockIssueService.getById.mockImplementation(async (id) => {
if (id === child.id && scenario === "lookup_failure") throw new Error("temporary read failure");
if (id === secondChild.id) return secondChild;
return id === child.id ? child : existing;
});
mockIssueService.getByIdentifier.mockImplementation(async (identifier) => {
if (scenario === "completed_lookup_failure") throw new Error("temporary identifier lookup failure");
return identifier === child.identifier ? child : null;
});
mockIssueService.update.mockImplementation(async (_id, patch) => scenario === "completed_explicit_resume" ? { ...existing, ...patch } : existing);
const body = ["blocked_parent", "done_parent"].includes(scenario)
? `[@QA](agent://${MENTIONED_AGENT_ID}) is relevant; see [${child.identifier}](/PAP/issues/${child.identifier}).`
: `Finished the task. [@QA](agent://${MENTIONED_AGENT_ID}) has relevant context.`;
const originalComment = {
id: "delegation-note", issueId: existing.id, companyId: existing.companyId, body,
createdByRunId: humanComment ? null : SOURCE_RUN_ID,
authorAgentId: null, authorUserId: "local-board", authorType: "user", onBehalfOfUserId: "responsible-user",
sourceTrust: { preset: "low_trust_review", disposition: "quarantined", sourceIssueId: existing.id, sourceRunId: SOURCE_RUN_ID },
id: "context-note", issueId: existing.id, companyId: existing.companyId, body,
createdByRunId: selfComment ? SOURCE_RUN_ID : null,
};
mockIssueService.addComment.mockImplementation(async (issueId, commentBody) => {
if (issueId === child.id && scenario === "forwarding_failure") throw new Error("temporary child comment failure");
return issueId === child.id ? { ...originalComment, id: "forwarded-note", issueId, body: commentBody } : originalComment;
});
mockIssueService.findMentionedAgents.mockResolvedValue([MENTIONED_AGENT_ID, PREVIOUS_AGENT_ID]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: completedDelegation && scenario !== "completed_delegation" ? [] : scenario === "ambiguous_delegation" ? [child, secondChild] : [child], blocks: [] });
if (scenario === "child_access_denied") {
mockAccessDecide.mockImplementation(async (input) => ({ allowed: input.resource?.issueId !== child.id, action: input.action, reason: "test_access_decision", explanation: "Test child access decision." }));
}
if (scenario === "child_mutation_denied") {
mockAccessDecide.mockImplementation(async (input) => ({ allowed: input.resource?.issueId !== child.id || input.action !== "issue:mutate", action: input.action, reason: "test_access_decision", explanation: "Test child mutation decision." }));
}
mockIssueService.getById.mockImplementation(async (id) => id === child.id ? child : existing);
mockIssueService.getByIdentifier.mockResolvedValue(child);
mockIssueService.update.mockResolvedValue(scenario === "closing_comment" ? { ...existing, status: "done" } : existing);
mockIssueService.addComment.mockResolvedValue(originalComment);
mockIssueService.findMentionedAgents.mockResolvedValue([MENTIONED_AGENT_ID]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [child], blocks: [] });
mockHeartbeatService.getRun.mockImplementation(async (id) => ({
id, companyId: id === child.executionRunId && scenario === "foreign_run" ? "other-company" : existing.companyId,
status: id === child.executionRunId && scenario === "stopped_run" ? "succeeded" : "running",
agentId: id === child.executionRunId || id === secondChild.executionRunId ? MENTIONED_AGENT_ID : ASSIGNEE_AGENT_ID,
contextSnapshot: { issueId: id === secondChild.executionRunId ? secondChild.id : id === child.executionRunId && scenario !== "unrelated_run" ? child.id : scenario.endsWith("source_run_other_issue") ? "other-source-issue" : existing.id },
id, companyId: existing.companyId, status: "running",
agentId: id === SOURCE_RUN_ID ? ASSIGNEE_AGENT_ID : MENTIONED_AGENT_ID,
contextSnapshot: { issueId: id === SOURCE_RUN_ID ? existing.id : child.id },
}));
const app = await createApp();
const req = method === "post"
? request(app).post(`/api/issues/${existing.id}/comments`)
: request(app).patch(`/api/issues/${existing.id}`);
if (!humanComment) req.set("X-Paperclip-Run-Id", SOURCE_RUN_ID);
const explicitResume = scenario === "completed_explicit_resume" ? { resume: true } : {};
const res = await req.send(method === "post" ? { body, ...explicitResume } : { comment: body, ...explicitResume });
if (selfComment) req.set("X-Paperclip-Run-Id", SOURCE_RUN_ID);
const res = await req.send(method === "post" ? { body } : { comment: body, ...(scenario === "closing_comment" ? { status: "done" } : {}) });
expect(res.status).toBe(method === "post" ? 201 : 200);
await vi.waitFor(() => {
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(PREVIOUS_AGENT_ID, expect.objectContaining({ reason: "issue_comment_mentioned" }));
});
if (scenario === "active_delegation" || scenario === "active_feedback") {
expect(mockIssueService.addComment).toHaveBeenCalledWith(
child.id,
`Forwarded from [${existing.identifier}](/issues/${existing.identifier}#comment-${originalComment.id}):\n\n${body}`,
{ agentId: undefined, userId: "local-board", runId: SOURCE_RUN_ID, onBehalfOfUserId: "responsible-user" },
expect.objectContaining({ authorType: "user", sourceTrust: originalComment.sourceTrust }),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(MENTIONED_AGENT_ID, expect.objectContaining({
reason: "issue_comment_mentioned",
payload: expect.objectContaining({ issueId: child.id, commentId: "forwarded-note", resumeIntent: true, followUpRequested: true }),
contextSnapshot: expect.objectContaining({ issueId: child.id, taskId: child.id, commentId: "forwarded-note", wakeCommentId: "forwarded-note", source: "comment.mention.delegation", resumeIntent: true, followUpRequested: true }),
// Wake scheduling runs after sending the response. Drain its resolved
// mock promises before asserting that no extra work was dispatched.
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mockIssueService.addComment).toHaveBeenCalledTimes(1);
expect(mockIssueService.addComment.mock.calls[0].slice(0, 2)).toEqual([existing.id, body]);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(scenario === "board_comment" ? 1 : 0);
if (scenario === "board_comment") {
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(ASSIGNEE_AGENT_ID, expect.objectContaining({
reason: "issue_commented", contextSnapshot: expect.objectContaining({ issueId: existing.id }),
}));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(MENTIONED_AGENT_ID, expect.objectContaining({ payload: expect.objectContaining({ issueId: existing.id }) }));
} else if (["completed_delegation", "completed_delegation_without_blocker", "completed_parent_reference"].includes(scenario)) {
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(MENTIONED_AGENT_ID, expect.objectContaining({ reason: "issue_comment_mentioned" }));
} else {
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(MENTIONED_AGENT_ID, expect.objectContaining({ reason: "issue_comment_mentioned", payload: expect.objectContaining({ issueId: existing.id }) }));
if (scenario !== "forwarding_failure") {
expect(mockIssueService.addComment.mock.calls.some(([issueId]) => issueId === child.id)).toBe(false);
}
}
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockIssueService.update.mock.calls.every(([id]) => id === existing.id)).toBe(true);
});
it("preserves an explicit resume on a run-authenticated top-level comment", async () => {
@@ -981,55 +887,8 @@ describe("issue update comment wakeups", () => {
});
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledWith(
existing.companyId,
"QA please take the screenshot",
));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("routes a structured mentioned agent without making that agent the issue owner", async () => {
const existing = makeIssue({
assigneeAgentId: null,
assigneeUserId: "local-board",
status: "in_progress",
});
mockIssueService.getById.mockResolvedValue(existing);
mockIssueService.addComment.mockResolvedValue({
id: "comment-structured-mention",
issueId: existing.id,
companyId: existing.companyId,
body: "[@QA](/agents/33333333-3333-4333-8333-333333333333) please inspect this",
});
mockIssueService.findMentionedAgents.mockResolvedValue([MENTIONED_AGENT_ID]);
const res = await request(await createApp())
.post(`/api/issues/${existing.id}/comments`)
.send({
body: "[@QA](/agents/33333333-3333-4333-8333-333333333333) please inspect this",
});
expect(res.status).toBe(201);
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
MENTIONED_AGENT_ID,
expect.objectContaining({
source: "automation",
reason: "issue_comment_mentioned",
payload: {
issueId: existing.id,
commentId: "comment-structured-mention",
},
contextSnapshot: expect.objectContaining({
issueId: existing.id,
taskId: existing.id,
commentId: "comment-structured-mention",
wakeCommentId: "comment-structured-mention",
wakeReason: "issue_comment_mentioned",
source: "comment.mention",
}),
}),
);
});
});
+39 -8
View File
@@ -43,6 +43,8 @@ import {
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat.js";
import { resolveNativeRuntimeMcpSnapshot } from "../services/native-runtime/runtime-context.js";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "../routes/tool-gateway.js";
import { toolAccessService } from "../services/tool-access.js";
@@ -2348,7 +2350,9 @@ rl.on("line", (line) => {
}
});
it("creates a personal authorization card and resumes after the user grant exists", async () => {
it("starts with another user's personal app and requests authorization only when used", async () => {
const originalApiUrl = process.env.PAPERCLIP_API_URL;
process.env.PAPERCLIP_API_URL = "http://paperclip.example.test";
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
@@ -2362,7 +2366,7 @@ rl.on("line", (line) => {
},
}));
try {
const { connection } = await createRemoteMcpTool(db, company.id, {
const { connection, application, catalogEntry } = await createRemoteMcpTool(db, company.id, {
url: fake.url,
toolName: "whoami",
riskLevel: "read",
@@ -2376,9 +2380,33 @@ rl.on("line", (line) => {
healthMessage: "This app needs you to sign in.",
}).where(eq(toolConnections.id, connection.id));
await allowAllToolsForAgent(db, company.id, agent.id);
await createActiveMember(db, company.id, "alice");
await db.delete(connectionGrants).where(eq(connectionGrants.connectionId, connection.id));
await db.insert(connectionGrants).values({
companyId: company.id, connectionId: connection.id, kind: "user",
subjectUserId: "alice", credentialSecretRefs: [], status: "active", isDefault: false,
});
await db.insert(toolConnectionInstalls).values({
companyId: company.id, connectionId: connection.id, targetType: "agent", targetId: agent.id,
});
const snapshot = await resolveNativeRuntimeMcpSnapshot({ db, agent, runId: run.id });
expect(snapshot.bindingId).toBe(`native-mcp:${run.id}`);
const runtime = await buildPaperclipRuntimeMcpServers({ db, agent, runId: run.id, expectedAssignmentDigest: snapshot.digest });
expect(runtime).toHaveLength(1);
// Startup and tool discovery never contact the provider or ask Carol to sign in.
expect(fake.requests).toHaveLength(0);
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id))).toEqual([]);
const gateway = createTestToolGatewayService(db);
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.providerType === "mcp_remote_http")!;
const app = createGatewayRouteApp(db, gateway);
const endpoint = new URL(runtime[0]!.url!).pathname;
const listed = await request(app).post(endpoint).set("authorization", `Bearer ${runtime[0]!.token}`)
.send({ jsonrpc: "2.0", id: "list", method: "tools/list" }).expect(200);
const tool = listed.body.result.tools.find((entry: { name: string }) => entry.name === expectedConnectedToolName({
applicationKey: application.applicationKey, connectionId: connection.id, toolName: catalogEntry.toolName,
}));
expect(tool).toBeDefined();
const call = () => request(app).post(endpoint).set("authorization", `Bearer ${runtime[0]!.token}`)
.send({ jsonrpc: "2.0", id: "call", method: "tools/call", params: { name: tool.name, arguments: {} } });
await db.insert(issueThreadInteractions).values({
companyId: company.id,
@@ -2399,8 +2427,9 @@ rl.on("line", (line) => {
},
});
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
.rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
const missingGrant = await call().expect(409);
expect(missingGrant.body.error.data.reasonCode).toBe("user_authorization_required");
expect(fake.requests).toHaveLength(0); // Alice's grant must never authorize Carol's call.
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id));
expect(interaction).toMatchObject({
kind: "request_confirmation",
@@ -2424,13 +2453,15 @@ rl.on("line", (line) => {
status: "active",
isDefault: false,
});
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
expect(result).toMatchObject({ status: "completed", result: { content: "connected" } });
const result = await call().expect(200);
expect(result.body.result.content).toEqual([{ type: "text", text: "connected" }]);
expect(fake.requests).toHaveLength(1);
await expect(db.select({ healthStatus: toolConnections.healthStatus }).from(toolConnections).where(
eq(toolConnections.id, connection.id),
)).resolves.toEqual([{ healthStatus: "ok" }]);
} finally {
if (originalApiUrl === undefined) delete process.env.PAPERCLIP_API_URL;
else process.env.PAPERCLIP_API_URL = originalApiUrl;
await fake.close();
}
});
@@ -189,6 +189,23 @@ describe("releaseIssueExecution", () => {
},
);
it("preserves an accepted assignment when a legacy mention was coalesced last", async () => {
const assignedIssue = { ...ISSUE, assigneeAgentId: AGENT.id };
const queue = [wakeCandidate({
source: "assignment", reason: "issue_execution_deferred", wakeReason: "issue_comment_mentioned",
deferredContextSeed: { issueId: ISSUE.id, wakeReason: "issue_comment_mentioned", source: "comment.mention" },
})];
const transaction = createFakeTransaction({ findNextDeferredWake: vi.fn(async () => queue.shift() ?? null) });
const release = createReleaseIssueExecution({
issueLock: createFakeIssueLock(createFakeHost(), transaction, assignedIssue), recovery: createFakeRecovery(),
});
expect((await release({ companyId: RUN.companyId, runId: RUN.id, now: new Date() })).outcome.kind).toBe("promoted");
expect(transaction.cancelDeferredWake).not.toHaveBeenCalled();
expect(transaction.finalizePromotedWake).toHaveBeenCalledWith(expect.objectContaining({
source: "assignment", deferredAgent: expect.objectContaining({ id: AGENT.id }),
}));
});
it("preserves the former owner's queue for handoff adoption while draining the new owner's wake", async () => {
const stale = wakeCandidate({ agentId: RUN.agentId, queuedCommentIds: ["saved-user-direction"] });
const current = wakeCandidate({ id: "wake-new-owner", agentId: "new-agent" });
+1 -213
View File
@@ -4,7 +4,7 @@ import { queuedInteractionId, readQueuedInteractionResponse, hasQueuedInteractio
import { deliverConversationComments, isConversation } from "../services/agent-conversations.js";
import { issueRecoveryActionReadModel } from "../services/issue-recovery-actions.js";
import { getExecutionBlocker } from "../services/execution-blocker.js";
import { extractIssueReferenceIdentifiers, requiresExecutionReconciliation } from "@paperclipai/shared";
import { requiresExecutionReconciliation } from "@paperclipai/shared";
import {
validateExecutionReconciliation,
markExecutionReconciliation,
@@ -3684,132 +3684,6 @@ export function issueRoutes(
return memoizeIssueRead(req, id, () => svc.getById(id));
}
async function routeDelegationMention(
req: Request,
parent: { id: string; identifier?: string | null; companyId: string; status: string },
mentionedAgentId: string,
comment: IssueComment,
): Promise<{ kind: "completed" } | { kind: "forwarded"; issueId: string; commentId: string } | null> {
if (parent.status !== "blocked" && parent.status !== "done") return null;
const references = new Set(extractIssueReferenceIdentifiers(comment.body));
if (references.size === 0) return null;
let child: Awaited<ReturnType<typeof svc.getById>> = null;
let authorizationReason: string | undefined;
try {
const sourceRun = comment.createdByRunId
? await heartbeat.getRun(comment.createdByRunId)
: null;
// The same lead can be working on another task. Its cross-task
// request is not a progress or closing note from this parent run.
if (sourceRun?.contextSnapshot?.issueId !== parent.id) return null;
// A fast child can finish before the lead records a blocking relation.
// Completion notes use the direct parent-child relationship instead.
const blockers = parent.status === "blocked"
? (await svc.getRelationSummaries(parent.id)).blockedBy
: [];
for (const identifier of references) {
const blocker = blockers.find((candidate) =>
candidate.assigneeAgentId === mentionedAgentId && candidate.identifier === identifier);
const candidate = parent.status === "done"
? await svc.getByIdentifier(identifier)
: blocker ? await svc.getById(blocker.id) : null;
if (
!candidate || candidate.companyId !== parent.companyId ||
candidate.parentId !== parent.id || candidate.assigneeAgentId !== mentionedAgentId
) continue;
// Do not choose an arbitrary task when the comment names several.
if (child && child.id !== candidate.id) return null;
child = candidate;
}
if (!child) return null;
if (parent.status === "done" && child.status === "done") {
// A closing note can name its parent, but another task reference
// can be a separate request. Preserve that mention's normal wake.
const childIdentifier = child.identifier;
if ([...references].some((identifier) =>
identifier !== childIdentifier && identifier !== parent.identifier
)) return null;
logger.info({ issueId: parent.id, childIssueId: child.id, agentId: mentionedAgentId },
"skipped completed delegation mention wake");
return { kind: "completed" };
}
if (parent.status !== "blocked" || child.status !== "in_progress") return null;
const runId = child.executionRunId ?? child.checkoutRunId;
if (!runId) return null;
const run = await heartbeat.getRun(runId);
if (
!run || run.companyId !== parent.companyId || run.agentId !== mentionedAgentId ||
run.status !== "running" || run.contextSnapshot?.issueId !== child.id
) return null;
const decision = await decideIssueAccess(req, child, "issue:comment");
if (!decision.allowed) return null;
// Accepted feedback can need another child turn after this one
// finishes, so the author must also be allowed to resume its task.
if (!(await decideIssueAccess(req, child, "issue:mutate")).allowed) return null;
authorizationReason = decision.reason;
} catch (err) {
// A failed optimization must not drop an otherwise valid mention.
logger.warn({ err, issueId: parent.id }, "could not check delegation for mention");
return null;
}
let forwarded: IssueComment;
try {
const parentRef = parent.identifier ?? parent.id;
forwarded = await svc.addComment(
child.id,
`Forwarded from [${parentRef}](/issues/${parentRef}#comment-${comment.id}):\n\n${comment.body}`,
{
agentId: comment.authorAgentId ?? undefined,
userId: comment.authorUserId ?? undefined,
runId: comment.createdByRunId,
onBehalfOfUserId: comment.onBehalfOfUserId,
},
{ authorType: comment.authorType, sourceTrust: comment.sourceTrust, authorizationReason },
);
} catch (err) {
logger.warn({ err, issueId: parent.id, childIssueId: child.id }, "could not forward delegation mention");
return null;
}
// Heartbeat reads wake comments within the target issue. A linked copy
// preserves the full feedback and its trust/author provenance in that
// scope. Do not parse its mentions again: the child wake below is the
// only dispatch, and the existing issue queue serializes its delivery.
try {
await issueReferencesSvc.syncComment(forwarded.id);
} catch (err) {
logger.warn({ err, issueId: child.id, commentId: forwarded.id }, "could not index forwarded delegation comment");
}
try {
const actor = getActorInfo(req);
await logActivity(db, {
companyId: parent.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "issue.comment_added",
entityType: "issue",
entityId: child.id,
details: {
commentId: forwarded.id,
identifier: child.identifier,
issueTitle: child.title,
source: "comment.mention.delegation",
sourceIssueId: parent.id,
sourceCommentId: comment.id,
authorizationReason,
},
});
} catch (err) {
// The copy is already durable. Keep its child-scoped wake even if
// reference indexing or the activity publication needs recovery.
logger.warn({ err, issueId: child.id, commentId: forwarded.id }, "could not publish forwarded delegation comment activity");
}
return { kind: "forwarded", issueId: child.id, commentId: forwarded.id };
}
const issueDetailEtag = privateJsonEtag();
router.use((req, res, next) => {
if (/^\/issues\/[^/]+(?:\/|$)/.test(req.path)) {
@@ -14800,49 +14674,6 @@ export function issueRoutes(
},
});
}
let mentionedIds: string[] = [];
try {
mentionedIds = await svc.findMentionedAgents(
issue.companyId,
commentBody,
);
} catch (err) {
logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
}
for (const mentionedId of mentionedIds) {
if (
(actor.actorType === "agent" && actor.actorId === mentionedId) ||
(commentIsFromAssigneeRun && mentionedId === assigneeId)
)
continue;
const delegation = commentIsFromAssigneeRun
? await routeDelegationMention(req, issue, mentionedId, comment)
: null;
if (delegation?.kind === "completed") continue;
const wakeIssueId = delegation?.issueId ?? id;
const wakeCommentId = delegation?.commentId ?? comment.id;
addWakeup(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
payload: { issueId: wakeIssueId, commentId: wakeCommentId,
...(delegation ? { resumeIntent: true, followUpRequested: true } : {}),
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: wakeIssueId,
taskId: wakeIssueId,
commentId: wakeCommentId,
wakeCommentId,
wakeReason: "issue_comment_mentioned",
source: delegation ? "comment.mention.delegation" : "comment.mention",
...(delegation ? { resumeIntent: true, followUpRequested: true } : {}),
},
});
}
}
const becameDone =
@@ -18237,49 +18068,6 @@ export function issueRoutes(
}
}
let mentionedIds: string[] = [];
try {
mentionedIds = await svc.findMentionedAgents(
issue.companyId,
req.body.body,
);
} catch (err) {
logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
}
for (const mentionedId of mentionedIds) {
if (
(actorIsAgent && actor.actorId === mentionedId) ||
(commentIsFromAssigneeRun && mentionedId === assigneeId)
)
continue;
const delegation = commentIsFromAssigneeRun
? await routeDelegationMention(req, currentIssue, mentionedId, comment)
: null;
if (delegation?.kind === "completed") continue;
const wakeIssueId = delegation?.issueId ?? id;
const wakeCommentId = delegation?.commentId ?? comment.id;
addWakeup(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
payload: { issueId: wakeIssueId, commentId: wakeCommentId,
...(delegation ? { resumeIntent: true, followUpRequested: true } : {}),
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: wakeIssueId,
taskId: wakeIssueId,
commentId: wakeCommentId,
wakeCommentId,
wakeReason: "issue_comment_mentioned",
source: delegation ? "comment.mention.delegation" : "comment.mention",
...(delegation ? { resumeIntent: true, followUpRequested: true } : {}),
},
});
}
const becameDone =
issueBeforeCommentDecision.status !== "done" &&
currentIssue.status === "done";
+4 -40
View File
@@ -4566,9 +4566,6 @@ export async function buildPaperclipRuntimeMcpServers(input: {
agent: Pick<typeof agents.$inferSelect, "id" | "companyId" | "name">;
runId: string;
expectedAssignmentDigest?: string | null;
onUnavailableAssignedConnections?: (
connections: Array<{ id: string; name: string }>,
) => void | Promise<void>;
}): Promise<AdapterRuntimeMcpServer[]> {
const access = toolAccessService(input.db);
const effective = await access.getEffectiveProfilesForAgent(
@@ -4641,38 +4638,11 @@ export async function buildPaperclipRuntimeMcpServers(input: {
((Boolean(runIdentity?.activeIdentityContextId) &&
(connection.config?.sourceTemplateKey === "github" ||
connection.transportConfig?.sourceTemplateKey === "github")) ||
connection.credentialPolicy === "per_user" ||
!isToolConnectionAttentionHealth(connection.healthStatus)) &&
(connection.transport === "mcp_remote" ||
connection.transport === "local_stdio" || githubBotConnectionIds.has(connection.id)),
);
const unhealthyConnections = resolvedInstalledConnections.filter(
(connection) =>
permittedConnectionIds.has(connection.id) &&
(connection.transport === "mcp_remote" ||
connection.transport === "local_stdio") &&
(!connection.enabled ||
connection.status !== "active" ||
isToolConnectionAttentionHealth(connection.healthStatus)),
);
if (unhealthyConnections.length && input.onUnavailableAssignedConnections) {
try {
await input.onUnavailableAssignedConnections(
unhealthyConnections
.map(({ id, name }) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name)),
);
} catch (error) {
logger.warn(
{
companyId: input.agent.companyId,
agentId: input.agent.id,
runId: input.runId,
err: error,
},
"failed to report unavailable runtime MCP connections",
);
}
}
const assignedConnectionIds = new Set(
assignedConnections.map((connection) => connection.id),
);
@@ -24331,15 +24301,6 @@ export function heartbeatService(
agent,
runId: run.id,
expectedAssignmentDigest: expectedNativeMcpDigest,
onUnavailableAssignedConnections: async (connections) => {
const names = connections
.map((connection) => connection.name)
.join(", ");
await onLog(
"stderr",
`[paperclip] App connection${connections.length === 1 ? "" : "s"} unavailable: ${names}. Continuing this run without ${connections.length === 1 ? "it" : "them"}; reconnect from Apps to restore access.\n`,
);
},
});
if ("runtimeContext" in nativeExecution) {
if (nativeMcpServers.length > 1)
@@ -26539,6 +26500,9 @@ export function heartbeatService(
let agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found");
// Mentions only annotate comments. Ignore legacy callers before creating
// a run or deferred request; assignment and review have their own wakes.
if (reason === "issue_comment_mentioned" || enrichedContextSnapshot.wakeReason === "issue_comment_mentioned") return null;
if (issueId) {
const conversation = await getIssueExecutionContext(agent.companyId, issueId);
if (reason === "issue_children_completed" && conversation?.originKind === "onboarding_first_task") enrichedContextSnapshot.onboardingCompletion = true;
@@ -223,6 +223,7 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
&& connection.status === "active"
&& connection.enabled
&& (Boolean(runIdentity?.activeIdentityContextId) && (connection.config?.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github")
|| connection.credentialPolicy === "per_user"
|| !isToolConnectionAttentionHealth(connection.healthStatus))
&& (["mcp_remote", "local_stdio"].includes(connection.transport) || githubBotConnectionIds.has(connection.id))
).map((connection) => connection.id));
+4 -4
View File
@@ -110,10 +110,10 @@ Overrides and special cases:
- `PAPERCLIP_TASK_ID` set and assigned to you → prioritize that task first.
- `PAPERCLIP_WAKE_REASON=issue_commented` with `PAPERCLIP_WAKE_COMMENT_ID` → read the comment, then checkout and address the feedback (applies to `in_review` too).
- `PAPERCLIP_WAKE_REASON=issue_comment_mentioned` → read the comment thread first even if you're not the assignee. Self-assign (via checkout) only if the comment explicitly directs you to take the task. Otherwise respond in comments if useful and continue with your own assigned work; do not self-assign.
- Agent @-mentions are context only. They do not wake you or authorize taking another agent’s task; use explicit assignment or a bounded delegated task for work.
- Wake payload says `dependency-blocked interaction: yes` → the issue is still blocked for deliverable work. Do not try to unblock it. Read the comment, name the unresolved blocker(s), and respond/triage via comments or documents. Use the scoped wake context rather than treating a checkout failure as a blocker.
- **Blocked-task dedup:** before touching a `blocked` task, check the thread. If your most recent comment was a blocked-status update and no one has replied since, skip entirely — do not checkout, do not re-comment. Only re-engage on new context (comment, status change, event wake).
- Nothing assigned and no valid mention handoff → exit the heartbeat.
- Nothing assigned → exit the heartbeat.
**Step 5 — Checkout.** You MUST checkout before doing any work. Include the run ID header:
@@ -548,7 +548,7 @@ Exact response fields are documented in `skills/paperclip/references/api-referen
- **Never retry a 409.** The task belongs to someone else.
- **Never look for unassigned work.** No assignments = exit.
- **Self-assign only for explicit @-mention handoff.** Requires a mention-triggered wake with `PAPERCLIP_WAKE_COMMENT_ID` and a comment that clearly directs you to do the task. Use checkout (never direct assignee patch).
- **Never self-assign from a mention.** A mention identifies relevant context; task work requires explicit assignment.
- **Honor "send it back to me" requests from board users.** If a board/user asks for review handoff (e.g. "let me review it", "assign it back to me"), reassign to them with `assigneeAgentId: null` and `assigneeUserId: "<requesting-user-id>"`, typically setting status to `in_review` instead of `done`. Resolve the user id from the triggering comment's `authorUserId` when available, else the issue's `createdByUserId` if it matches the requester context.
- **Start actionable work before planning-only closure.** Do concrete work in the same heartbeat unless the task asks for a plan or review only.
- **Leave a next action.** Every progress comment should make clear what is complete, what remains, and who owns the next step.
@@ -558,7 +558,7 @@ Exact response fields are documented in `skills/paperclip/references/api-referen
- **Use first-class blockers** (`blockedByIssueIds`) rather than free-text "blocked by X" comments.
- **Say only what you actually scheduled.** Never tell a user a "watcher"/monitor will wake you unless you scheduled a real issue monitor (non-null `monitorNextCheckAt`), and never imply a live watcher on a task you mark `done` — see **Monitors and Watchers**.
- **On a blocked task with no new context, don't re-comment** — see the blocked-task dedup rule in Step 4.
- **@-mentions** trigger heartbeats — use sparingly, they cost budget. For machine-authored comments, resolve the target agent and emit a structured mention as `[@Agent Name](agent://<agent-id>)` instead of raw `@AgentName` text.
- **@-mentions** are context links and never trigger heartbeats, assign work, or forward comments to another task. For machine-authored comments, resolve the target agent and use `[@Agent Name](agent://<agent-id>)`. To request work, assign a task or use an explicit review request.
- **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only.
- **Handle blockers directly.** Identify the exact missing capability or authority. Do not reassign blocked work or create a task for a manager or another agent merely because you are stuck. A title or reporting line does not grant access or authority. For a human-only action, use the connection/approval flow when available; otherwise save a human-input interaction on the current task and leave it `in_review`. Delegate only when the recipient has a concrete capability needed for a bounded task, and never to bypass a permission denial.
- **Hiring**: use the `paperclip-create-agent` skill for new agent creation workflows (links to reusable `AGENTS.md` templates like `Coder` and `QA`).
+7 -23
View File
@@ -645,32 +645,16 @@ Use markdown formatting and include links to related entities when they exist:
Where `<prefix>` is the company prefix derived from the issue identifier (e.g., `PAP-123` → prefix is `PAP`).
**@-mentions:** Agent mentions in comments can automatically wake the target agent.
**@-mentions are context only.** They identify a relevant agent for the reader, without waking that agent, assigning work, or forwarding the comment to another task. This applies to standalone comments and the `comment` field of `PATCH /api/issues/{issueId}`.
For machine-authored comments, do not rely on raw `@AgentName` text. Raw text is unreliable for names containing spaces. Instead:
1. Resolve the target agent with `GET /api/companies/{companyId}/agents`
2. Find the agent's exact display name and `id`
3. Emit a structured markdown mention using the agent ID:
For machine-authored comments, resolve the agent’s ID with `GET /api/companies/{companyId}/agents` and use a structured link:
```
POST /api/issues/{issueId}/comments
{ "body": "[@QA Reviewer](agent://qa-agent-id) please review this implementation." }
{ "body": "[@QA Reviewer](agent://qa-agent-id) has relevant testing context." }
```
The reliable machine-authored format is `[@Display Name](agent://<agent-id>)`. This triggers a heartbeat for the mentioned agent. Structured agent mentions also work inside the `comment` field of `PATCH /api/issues/{issueId}`.
Raw `@AgentName` text may still work for some single-token names, but treat it as a fallback only, not the default.
**Do NOT:**
- Use @-mentions as your default assignment mechanism. If you need someone to do work, create/assign a task.
- Mention agents unnecessarily. Each mention triggers a heartbeat that costs budget.
**Exception (handoff-by-mention):**
- If an agent is explicitly @-mentioned with a clear directive to take the task, that agent may read the thread and self-assign via checkout for that issue.
- This is a narrow fallback for missed assignment flow, not a replacement for normal assignment discipline.
The normal assignee feedback path still applies to the comment. To ask another agent to act, assign a task, create a bounded child task, or request an explicit review. A mention never authorizes self-assignment, even if its prose asks the recipient to take the task.
---
@@ -1421,7 +1405,7 @@ Terminal states: `done`, `cancelled`
| POST | `/api/issues/:issueId/release` | Release execution locks; preserve terminal task ownership |
| GET | `/api/issues/:issueId/comments` | List comments |
| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID |
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions provide context) |
| POST | `/api/issues/:issueId/inbox-archive` | Archive issue from responsible user's inbox; optional `userId` requires saved target-user opt-in or cross-user grant |
| DELETE | `/api/issues/:issueId/inbox-archive` | Reverse inbox archive; same target and policy rules |
| GET | `/api/issues/:issueId/interactions` | List issue-thread interactions |
@@ -1666,12 +1650,12 @@ Every successful or failed value fetch writes both `secret_access_events` and `a
| ------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
| Start work without checkout | Another agent may claim it simultaneously | Always `POST /issues/:id/checkout` first |
| Retry a `409` checkout | The task belongs to someone else | Pick a different task |
| Look for unassigned work | You're overstepping; managers assign work | If you have no assignments, exit, except explicit mention handoff |
| Look for unassigned work | You're overstepping; managers assign work | If you have no assignments, exit |
| Exit without commenting on in-progress work | Your manager can't see progress; work appears stalled | Leave a comment explaining where you are |
| Create tasks without `parentId` | Breaks the task hierarchy; work becomes untraceable | Link every subtask to its parent |
| Cancel cross-team tasks | Only the assigning team's manager can cancel | Request a decision through a saved interaction |
| Ignore budget warnings | You'll be auto-paused at 100% mid-work | Check spend at start; prioritize above 80% |
| @-mention agents for no reason | Each mention triggers a budget-consuming heartbeat | Only mention agents who need to act |
| Expect an @-mention to dispatch work | Mentions are context only | Assign a task or request an explicit review |
| Sit silently on blocked work | Nobody knows you're stuck; the task rots | Record the blocker and use a saved interaction or dependency |
| Leave tasks in ambiguous states | Others can't tell if work is progressing | Always update status: `blocked`, `in_review`, or `done` |
| Block on another task without `blockedByIssueIds` | No automatic wake when blocker resolves; manual follow-up needed | Set `blockedByIssueIds` so Paperclip auto-wakes the assignee when all blockers are done |