Skip to content

Make the agent→human hand-off visible to plugins - #3

Merged
elysosss merged 1 commit into
fork/mainfrom
feat/plugin-interaction-events
Aug 11, 2026
Merged

elysosss merged 1 commit into
fork/mainfrom
feat/plugin-interaction-events

Conversation

@elysosss

Copy link
Copy Markdown
Owner

Closes agent-company-kit#13.

When an agent hands a task to a human it opens a request_confirmation issue-thread interaction — it cannot park the task as blocked naming a person as unblock owner, that is a deliberate 403. Creating the interaction logged activity as issue.thread_interaction_created, which was in neither PLUGIN_EVENT_TYPES nor the activity-to-event map, so eventTypeForActivityAction returned null and no plugin ever saw it. The terminal state of a maintenance loop produced no notification on any surface.

The change

issue.interaction.created and issue.interaction.resolved join PLUGIN_EVENT_TYPES, mapped from the eight thread-interaction activity actions. All seven ways an interaction ends collapse to one resolved event — the same shape the three approval decisions already collapse to approval.decided. A subscriber that showed "waiting for you" needs to know the wait is over, not which exit it took; the exit stays in payload.interactionStatus.

item_verdicts_submitted is in the resolved set even though a partial submission leaves the interaction pending. The event name cannot depend on the payload, interactionStatus is authoritative, and a second name for the same lifecycle would be worse. The notifier holds its dedup slot while the status is still pending.

The payload was checked, not assumed. entityId is the issue id; details carry interactionId, interactionKind, interactionStatus, addresseeAgentId and both resolver policies. No key matches the redactor's secret pattern and no value is JWT-shaped, so everything survives redactActivityDetails — pinned by a test, because an event whose payload is redacted to nothing is worse than no event.

telegram-notify subscribes to both. Its existing blocked-transition path stays; the two share a claim/release slot so one hand-off produces exactly one message in either arrival order.

Verified

Check Result
@paperclipai/shared / @paperclipai/server / plugin typecheck clean
new server/src/__tests__/activity-log-plugin-events.test.ts 12 passed
activity + plugin-event server suites (5 files) 46 passed
paperclip-plugin-telegram-notify 48 passed, was 36
packages/shared / packages/plugins/sdk 425 / 44 passed, no regressions

Scope

Additive only: nothing removed, nothing renamed, the 403 policy untouched, no existing event changed. 520 insertions, 6 deletions. Worth offering upstream — this is a gap in the host's event surface, not a fork concern.

An agent that needs a person to decide something cannot park the task as
`blocked` and name that person as the unblock owner — the board answers
`403 Agents may only name themselves as an unblock owner`, deliberately.
What it does instead is open an issue-thread interaction and stop. That
logs `issue.thread_interaction_created`, an action in neither
PLUGIN_EVENT_TYPES nor ACTIVITY_ACTION_TO_PLUGIN_EVENT, so
eventTypeForActivityAction returned null and no plugin ever saw it. The
terminal state of a maintenance loop produced no notification anywhere.

Adds `issue.interaction.created` and `issue.interaction.resolved`.

Which actions collapse into `resolved`, and why: accepted, rejected,
answered, withdrawn, cancelled, expired and item_verdicts_submitted all
end the same lifecycle, and a subscriber that showed "waiting for you"
only needs to know the wait is over — not which of the seven exits it
took. That is the shape approval.decided already has for its three
decision actions. The exit stays readable in `payload.interactionStatus`.

item_verdicts_submitted is in that set even though a partial submission
leaves the interaction pending. The event name cannot depend on the
payload, and a second event name for the same lifecycle would be worse
than one event whose status field is authoritative — which is asserted.

The payload a subscriber gets is entityId (the issue), interactionId,
interactionKind, interactionStatus and addresseeAgentId. None of those
key names match the redactor's secret pattern and none of the values are
JWT-shaped, so all of them survive redactActivityDetails; there is a test
pinning that, because it is the redactor's decision and not ours.

The Telegram notifier subscribes and sends its existing "waiting for you"
message on the new path. Its old path — the `issue.updated` transition
into `blocked` — is unchanged and still catches the parkings an agent
does not cause. Since one hand-off can produce both signals, both paths
now claim a single per-issue slot in plugin state before sending, and
only the signal that claimed it releases it: the board path when the task
leaves `blocked`, the interaction path on `issue.interaction.resolved`.
A partial verdict submission holds the slot, because nobody is off the
hook yet. Messages stay identifiers and links: the interaction *kind* is
named, the question is not.

Interactions addressed to an agent are skipped — the agent loop answers
those and no phone is involved.

Additive throughout. No existing event changed, no activity action added
or renamed, and the 403 policy is untouched.

server 12 new tests in activity-log-plugin-events; telegram-notify 36 to 48.
@elysosss
elysosss merged commit e76127b into fork/main Aug 11, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant