From beeabc4ff3fa965b2817c0fd378a66a6d754bdd0 Mon Sep 17 00:00:00 2001 From: elysosss Date: Mon, 10 Aug 2026 18:41:07 +0000 Subject: [PATCH] Make the agent to human hand-off visible to plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- doc/plugins/PLUGIN_SPEC.md | 2 + .../README.md | 24 ++- .../src/constants.ts | 11 + .../src/format.ts | 28 +++ .../src/manifest.ts | 2 +- .../src/worker.ts | 99 ++++++++- .../tests/plugin.spec.ts | 141 ++++++++++++- packages/plugins/sdk/README.md | 1 + packages/shared/src/constants.ts | 2 + .../activity-log-plugin-events.test.ts | 196 ++++++++++++++++++ server/src/services/activity-log.ts | 20 ++ 11 files changed, 520 insertions(+), 6 deletions(-) create mode 100644 server/src/__tests__/activity-log-plugin-events.test.ts diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 31a01547076d..04ac2e700373 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -909,6 +909,8 @@ Minimum event set: - `issue.checked_out` - `issue.released` - `issue.assignment_wakeup_requested` +- `issue.interaction.created` +- `issue.interaction.resolved` - `agent.created` - `agent.updated` - `agent.status_changed` diff --git a/packages/plugins/paperclip-plugin-telegram-notify/README.md b/packages/plugins/paperclip-plugin-telegram-notify/README.md index 9250937c7eab..3ae668a4c4ba 100644 --- a/packages/plugins/paperclip-plugin-telegram-notify/README.md +++ b/packages/plugins/paperclip-plugin-telegram-notify/README.md @@ -24,6 +24,7 @@ cached or logged), the mirror's failure discipline (every handler wrapped, failu | `agent.run.failed` | **Run failed** — agent, run id, truncated error | A failed run still spends the day's cap, so this means today is over, not that it will retry | | `budget.incident.opened` / `.resolved` | **Budget stopped work** / resolved | Money stopped, or restarted | | `issue.updated` → `blocked` | **Waiting for you** — task key and the unblock action | A task became somebody's to decide | +| `issue.interaction.created` | **Waiting for you** — task key and what kind of answer is wanted | An agent asked a person directly and stopped | The spec allowed three and said a fourth needs an argument. The argument for the fourth: every loop in this kit that produces work for the owner terminates in a parked task — the completion @@ -43,6 +44,23 @@ The message leads with the `unblockDescriptor.action` when there is one. "Merge is something you can act on from a phone; "blocked" is not. When the unblock owner is a named user it says **Waiting for you**; when it is `board` — meaning anyone — it says **Task parked**. +**Watching the board state alone was still not enough.** An agent that wants a person to decide +something cannot park the task and name that person as the unblock owner: the board answers +`403 Agents may only name themselves as an unblock owner`. What it does instead is open an +issue-thread interaction — a confirmation, a question, a list of verdicts — and stop. The task's +status never moves, so the transition above never fires, and that hand-off reached no surface at +all ([kit #13](https://github.com/elysosss/agent-company-kit/issues/13)). `issue.interaction.created` +is the second path to the same message. Only interactions with no `addresseeAgentId` count: one +addressed to an agent is answered by the agent loop and is nobody's business on a phone. The +message names the interaction *kind*, not the question — the question stays on the board. + +**One hand-off sends one message.** A task can produce both signals — an agent opens an +interaction, then somebody parks the task because of it — and they are the same event. Both paths +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 resolves some items and leaves the +interaction pending; the slot stays held, because nobody is off the hook yet. + **The escalation event is plugin-to-plugin, and its name is namespaced**: `plugin.paperclip-plugin-escalation.escalation-raised`. The emitter passes the bare name; every subscriber must use the prefixed one. Subscribing to the bare name compiles, runs, and silently @@ -105,12 +123,14 @@ for ever — that is the mirror's open bug ([kit #11](https://github.com/elysoss and there was no reason to reproduce it here. Replay is handled for the parked-task message: the last seen status per issue is kept in plugin -state, so a re-delivered `issue.updated` does not buzz the phone twice. +state, so a re-delivered `issue.updated` does not buzz the phone twice. The same state holds the +hand-off slot described above, which also makes a re-delivered `issue.interaction.created` +harmless. ## Tests ```bash -pnpm test # 32, offline — no board, no network, fetch stubbed +pnpm test # 48, offline — no board, no network, fetch stubbed pnpm typecheck ``` diff --git a/packages/plugins/paperclip-plugin-telegram-notify/src/constants.ts b/packages/plugins/paperclip-plugin-telegram-notify/src/constants.ts index 1cfc5a338793..1ea05cc493c7 100644 --- a/packages/plugins/paperclip-plugin-telegram-notify/src/constants.ts +++ b/packages/plugins/paperclip-plugin-telegram-notify/src/constants.ts @@ -20,6 +20,17 @@ export const ESCALATION_EVENT = "plugin.paperclip-plugin-escalation.escalation-r export const STATE_KEYS = { /** The status this issue was in when we last looked. */ lastStatus: "telegram:last-status", + /** + * The hand-off we last said "waiting for you" about, or absent when the issue + * is not currently waiting on anyone as far as we told the operator. + * + * Two different signals mean the same hand-off — the task parking as + * `blocked`, and an agent opening an issue-thread interaction — and a task can + * produce both. One key shared by both paths is what makes a hand-off buzz the + * phone once. The value says which signal claimed it, so only that signal's + * ending clears it: `status:blocked`, or `interaction:`. + */ + waitingNotified: "telegram:waiting-notified", } as const; /** diff --git a/packages/plugins/paperclip-plugin-telegram-notify/src/format.ts b/packages/plugins/paperclip-plugin-telegram-notify/src/format.ts index 486da7d68f9d..451a0e8deb3b 100644 --- a/packages/plugins/paperclip-plugin-telegram-notify/src/format.ts +++ b/packages/plugins/paperclip-plugin-telegram-notify/src/format.ts @@ -132,3 +132,31 @@ export function formatWaitingForHuman(input: { lines.push(input.action ? escapeHtml(clip(input.action, 200)) : "No unblock action was recorded."); return withLink(lines, input.link); } + +/** + * The other half of the same hand-off. An agent that wants a person to decide + * something cannot park the task and name that person as the unblock owner — + * the board refuses an agent naming anyone but itself — so it opens an + * issue-thread interaction instead and stops. That is the ending of most + * maintenance loops, and until this message it produced no notification at all. + * + * The kind is an enum, not the question. What was actually asked stays on the + * board, same rule as everywhere else in this file. + */ +const INTERACTION_ASKS: Record = { + request_confirmation: "An agent is waiting for you to confirm something.", + request_checkbox_confirmation: "An agent is waiting for you to confirm something.", + ask_user_questions: "An agent asked you a question.", + request_item_verdicts: "An agent is waiting for your verdict on a list of items.", + suggest_tasks: "An agent suggested tasks for you to accept or reject.", +}; + +export function formatWaitingForInteraction(input: { + issue: IssueRef | null; + issueId: string | null; + kind: string | null; + link: string | null; +}): string { + const ask = (input.kind ? INTERACTION_ASKS[input.kind] : null) ?? "An agent is waiting on you."; + return withLink(["Waiting for you", issueLabel(input.issue, input.issueId), ask], input.link); +} diff --git a/packages/plugins/paperclip-plugin-telegram-notify/src/manifest.ts b/packages/plugins/paperclip-plugin-telegram-notify/src/manifest.ts index 1c1ab76ee6ad..b7df3615a88c 100644 --- a/packages/plugins/paperclip-plugin-telegram-notify/src/manifest.ts +++ b/packages/plugins/paperclip-plugin-telegram-notify/src/manifest.ts @@ -80,7 +80,7 @@ const manifest: PaperclipPluginManifestV1 = { type: "boolean", title: "Notify when a task is parked for you", description: - "The completion check parking an abandoned task, or a reviewer that will not merge. This is where every loop that produces work for you ends.", + "The completion check parking an abandoned task, a reviewer that will not merge, or an agent opening a question or confirmation for you. This is where every loop that produces work for you ends. One hand-off sends one message however it reaches us.", default: true, }, }, diff --git a/packages/plugins/paperclip-plugin-telegram-notify/src/worker.ts b/packages/plugins/paperclip-plugin-telegram-notify/src/worker.ts index 92ea10576cda..693f5a583334 100644 --- a/packages/plugins/paperclip-plugin-telegram-notify/src/worker.ts +++ b/packages/plugins/paperclip-plugin-telegram-notify/src/worker.ts @@ -12,6 +12,7 @@ import { formatEscalation, formatRunFailure, formatWaitingForHuman, + formatWaitingForInteraction, type IssueRef, } from "./format.js"; import { TelegramApiError, TelegramClient } from "./telegram.js"; @@ -75,8 +76,36 @@ function num(raw: Record, key: string): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } +/** The token the board-status path claims a hand-off with. */ +const STATUS_HANDOFF = "status:blocked"; + const plugin = definePlugin({ async setup(ctx) { + const waitingKey = (issueId: string) => ({ + scopeKind: "issue" as const, + scopeId: issueId, + stateKey: STATE_KEYS.waitingNotified, + }); + + /** + * Takes the issue's single "already told them" slot, or reports that some + * other signal holds it. Both hand-off paths go through here, which is what + * stops one hand-off — an interaction opened, and the task parked because of + * it — from buzzing the phone twice. + */ + const claimHandoff = async (issueId: string, token: string): Promise => { + const held = await ctx.state.get(waitingKey(issueId)); + if (typeof held === "string" && held.length > 0) return false; + await ctx.state.set(waitingKey(issueId), token); + return true; + }; + + /** Releases the slot, but only for the signal that took it. */ + const releaseHandoff = async (issueId: string, token: string): Promise => { + if ((await ctx.state.get(waitingKey(issueId))) !== token) return; + await ctx.state.delete(waitingKey(issueId)); + }; + /** * Sends to every allowlisted chat. One failing recipient must not silence * the others, so each send is guarded on its own — the alternative loses @@ -251,7 +280,15 @@ const plugin = definePlugin({ const seen = await ctx.state.get({ ...scope, stateKey: STATE_KEYS.lastStatus }); const previous = typeof seen === "string" ? seen : null; await ctx.state.set({ ...scope, stateKey: STATE_KEYS.lastStatus }, issue.status); - if (issue.status !== WAITING_STATUS || previous === WAITING_STATUS) return; + if (issue.status !== WAITING_STATUS) { + // Off the parked status is where this path's hand-off ends. + await releaseHandoff(issueId, STATUS_HANDOFF); + return; + } + if (previous === WAITING_STATUS) return; + // An interaction on this task already said "waiting for you" — the + // parking is the same hand-off arriving by its other signal. + if (!(await claimHandoff(issueId, STATUS_HANDOFF))) return; const descriptor = issue.unblockDescriptor as | { owner?: unknown; action?: unknown } @@ -282,6 +319,66 @@ const plugin = definePlugin({ }), ); + /** + * The hand-off the board state cannot show. An agent that needs a person to + * decide something is not allowed to park the task and name that person as + * the unblock owner — naming anyone but itself is a 403 — so it opens an + * issue-thread interaction and stops. The task stays in its current status, + * so the `issue.updated` path above never fires, and before this handler the + * terminal state of a maintenance loop reached no surface at all. + */ + ctx.events.on("issue.interaction.created", (event) => + guard(ctx, "issue.interaction.created", async () => { + const issueId = event.entityId; + if (!issueId) return; + const config = await readConfig(ctx, event.companyId); + if (!config?.notify.waitingForHuman) return; + + const raw = payloadOf(event); + // Addressed to an agent means the agent loop answers it; a phone has + // nothing to do with it. Only an unaddressed pending interaction is a + // hand-off to a human. + if (str(raw, "addresseeAgentId")) return; + if (str(raw, "interactionStatus") !== "pending") return; + const interactionId = str(raw, "interactionId"); + if (!interactionId) return; + if (!(await claimHandoff(issueId, `interaction:${interactionId}`))) return; + + const issue = await lookupIssue(ctx, issueId, event.companyId); + await broadcast( + config, + event.companyId, + "waiting-for-human", + formatWaitingForInteraction({ + issue, + issueId, + kind: str(raw, "interactionKind"), + link: boardLink(config.boardBaseUrl, issue ?? { id: issueId, key: null, title: null }), + }), + issueId, + ); + }), + ); + + /** + * Answered, rejected, withdrawn, expired — the wait is over, so the slot is + * free for the next hand-off on this task. Deliberately not gated on the + * config toggle: state has to stay honest even while messages are switched + * off, or turning them back on finds a claim nothing will ever release. + */ + ctx.events.on("issue.interaction.resolved", (event) => + guard(ctx, "issue.interaction.resolved", async () => { + const issueId = event.entityId; + if (!issueId) return; + const raw = payloadOf(event); + const interactionId = str(raw, "interactionId"); + // A partial verdict submission resolves some items and leaves the + // interaction pending. Nobody is off the hook yet. + if (!interactionId || str(raw, "interactionStatus") === "pending") return; + await releaseHandoff(issueId, `interaction:${interactionId}`); + }), + ); + ctx.logger.info("Telegram notify ready"); }, diff --git a/packages/plugins/paperclip-plugin-telegram-notify/tests/plugin.spec.ts b/packages/plugins/paperclip-plugin-telegram-notify/tests/plugin.spec.ts index 0c83b2a1b382..852c86335a7f 100644 --- a/packages/plugins/paperclip-plugin-telegram-notify/tests/plugin.spec.ts +++ b/packages/plugins/paperclip-plugin-telegram-notify/tests/plugin.spec.ts @@ -5,7 +5,7 @@ import manifest from "../src/manifest.js"; import plugin from "../src/worker.js"; import { ESCALATION_EVENT } from "../src/constants.js"; import { parseConfig } from "../src/config.js"; -import { boardLink, clip, formatRunFailure, issueLabel } from "../src/format.js"; +import { boardLink, clip, formatRunFailure, formatWaitingForInteraction, issueLabel } from "../src/format.js"; import { escapeHtml, TelegramApiError } from "../src/telegram.js"; const COMPANY_ID = "c_1"; @@ -137,7 +137,48 @@ async function setup( ...overrides, } as Partial); - return { harness, sent: telegram.sent, undelivered, escalate, failRun, park, setStatus }; + /** + * The hand-off the board state cannot show: an agent that needs a person to + * decide something opens an issue-thread interaction and stops, because + * parking the task and naming that person as the unblock owner is a 403. The + * task's status does not move, so nothing else here fires. + */ + const handoff = (payload: Record = {}) => + harness.emit( + "issue.interaction.created", + { + interactionId: "int_1", + interactionKind: "request_confirmation", + interactionStatus: "pending", + addresseeAgentId: null, + ...payload, + }, + { entityId: ISSUE_ID, entityType: "issue", companyId: COMPANY_ID }, + ); + + const resolveHandoff = (payload: Record = {}) => + harness.emit( + "issue.interaction.resolved", + { + interactionId: "int_1", + interactionKind: "request_confirmation", + interactionStatus: "answered", + ...payload, + }, + { entityId: ISSUE_ID, entityType: "issue", companyId: COMPANY_ID }, + ); + + return { + harness, + sent: telegram.sent, + undelivered, + escalate, + failRun, + park, + setStatus, + handoff, + resolveHandoff, + }; } describe("the allowlist is the whole authorisation model", () => { @@ -250,6 +291,96 @@ describe("what reaches the phone", () => { }); }); +describe("an agent asking a person is a hand-off too", () => { + it("reports an interaction the agent opened for a human", async () => { + const { sent, handoff } = await setup(); + await handoff(); + expect(sent).toHaveLength(1); + expect(sent[0].text).toContain("Waiting for you"); + expect(sent[0].text).toContain("KIT-9"); + expect(sent[0].text).toContain("confirm"); + expect(sent[0].text).toContain(`https://board.example/issues/${ISSUE_ID}`); + }); + + it("carries no task content on a hand-off either", async () => { + const { sent, handoff } = await setup(); + await handoff({ interactionKind: "ask_user_questions" }); + expect(sent[0].text).not.toContain("must never leave the machine"); + }); + + it("says nothing about an interaction addressed to another agent", async () => { + // That one is answered by the agent loop; no phone is involved. + const { sent, handoff } = await setup(); + await handoff({ addresseeAgentId: "agent_2" }); + expect(sent).toEqual([]); + }); + + it("says nothing about an interaction that is already resolved", async () => { + const { sent, handoff } = await setup(); + await handoff({ interactionStatus: "accepted" }); + expect(sent).toEqual([]); + }); + + it("respects notifyWaitingForHuman", async () => { + const { sent, handoff } = await setup({ config: { notifyWaitingForHuman: false } }); + await handoff(); + expect(sent).toEqual([]); + }); +}); + +describe("one hand-off, one message", () => { + it("sends once when the interaction is followed by the task being parked", async () => { + // Both signals mean the same thing happened. The second is not news. + const { sent, handoff, park } = await setup(); + await handoff(); + await park(); + expect(sent).toHaveLength(1); + expect(sent[0].text).toContain("Waiting for you"); + }); + + it("sends once when the task is parked first and the interaction follows", async () => { + const { sent, park, handoff } = await setup(); + await park(); + await handoff(); + expect(sent).toHaveLength(1); + expect(sent[0].text).toContain("Merge or close PR #14"); + }); + + it("reports the next hand-off once the first one was answered", async () => { + const { sent, handoff, resolveHandoff } = await setup(); + await handoff(); + await resolveHandoff(); + await handoff({ interactionId: "int_2" }); + expect(sent).toHaveLength(2); + }); + + it("keeps the wait open when only some items got a verdict", async () => { + // A partial verdict submission leaves the interaction pending, so nobody is + // off the hook and the slot is not free for a second message. + const { sent, handoff, resolveHandoff } = await setup(); + await handoff(); + await resolveHandoff({ interactionStatus: "pending" }); + await handoff({ interactionId: "int_2" }); + expect(sent).toHaveLength(1); + }); + + it("reports a parking again once the interaction that claimed it was resolved", async () => { + const { sent, handoff, resolveHandoff, park } = await setup(); + await handoff(); + await resolveHandoff(); + await park(); + expect(sent).toHaveLength(2); + }); + + it("does not let a resolved interaction free a slot the board status holds", async () => { + const { sent, park, resolveHandoff, handoff } = await setup(); + await park(); + await resolveHandoff(); + await handoff({ interactionId: "int_2" }); + expect(sent).toHaveLength(1); + }); +}); + describe("each notification can be switched off alone", () => { it("respects notifyEscalation", async () => { const { sent, escalate } = await setup({ config: { notifyEscalation: false } }); @@ -346,6 +477,12 @@ describe("the pieces on their own", () => { expect(clip("x".repeat(50), 10).endsWith("…")).toBe(true); }); + it("names the ask by kind, and stays vague about a kind it does not know", () => { + const base = { issue: null, issueId: "iss_1", link: null }; + expect(formatWaitingForInteraction({ ...base, kind: "ask_user_questions" })).toContain("asked you a question"); + expect(formatWaitingForInteraction({ ...base, kind: "invented_later" })).toContain("waiting on you"); + }); + it("falls back to the bare id when the board gave us nothing", () => { expect(issueLabel(null, "iss_42")).toBe("iss_42"); }); diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index f352df1d9001..f53a6f0b7b2b 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -122,6 +122,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, | `issue.created`, `issue.updated`, `issue.comment.created` | issue | | `issue.document.created`, `issue.document.updated`, `issue.document.deleted` | issue | | `issue.relations.updated`, `issue.checked_out`, `issue.released`, `issue.assignment_wakeup_requested` | issue | +| `issue.interaction.created`, `issue.interaction.resolved` | issue | | `agent.created`, `agent.updated`, `agent.status_changed` | agent | | `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run | | `goal.created`, `goal.updated` | goal | diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index edad4e924fcc..1e476a26ec7c 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1621,6 +1621,8 @@ export const PLUGIN_EVENT_TYPES = [ "issue.checked_out", "issue.released", "issue.assignment_wakeup_requested", + "issue.interaction.created", + "issue.interaction.resolved", "agent.created", "agent.updated", "agent.status_changed", diff --git a/server/src/__tests__/activity-log-plugin-events.test.ts b/server/src/__tests__/activity-log-plugin-events.test.ts new file mode 100644 index 000000000000..d8e72b565955 --- /dev/null +++ b/server/src/__tests__/activity-log-plugin-events.test.ts @@ -0,0 +1,196 @@ +/** + * The activity log is the only place a plugin event is born, and an action that + * is in neither `PLUGIN_EVENT_TYPES` nor the alias table is written to the log + * and reaches no subscriber at all. That is a silent failure — nothing throws, + * nothing warns, the plugin simply never fires — so the mapping needs a test + * that goes all the way through the bus rather than reading the table back. + * + * The specific hole this covers: an agent handing a task to a human opens an + * issue-thread interaction (it may not park the task and name a person as the + * unblock owner — that is a 403), and before `issue.interaction.created` that + * hand-off produced no plugin event on any surface. + */ +import { describe, expect, it } from "vitest"; +import { activityLog, companies, instanceSettings, issues, type Db } from "@paperclipai/db"; +import type { PluginEvent } from "@paperclipai/plugin-sdk"; +import { logActivity, setPluginEventBus } from "../services/activity-log.js"; +import { createPluginEventBus } from "../services/plugin-event-bus.js"; + +const companyId = "00000000-0000-4000-8000-000000000001"; +const issueId = "00000000-0000-4000-8000-000000000003"; +const interactionId = "00000000-0000-4000-8000-000000000008"; + +/** + * Enough of a `Db` for `logActivity`: the instance-settings read behind the + * redactor, the responsible-user lookups, and the insert. Deliberately not a + * real database — what is under test is which event comes out, not what lands + * in a table. + */ +function stubDb(): Db { + const rowsByTable = new Map>>([ + [instanceSettings, [{ id: "settings-1", general: {}, experimental: {} }]], + [issues, []], + [companies, []], + ]); + return { + select: () => ({ + from: (table: unknown) => { + const result = Promise.resolve(rowsByTable.get(table) ?? []); + return { + where: () => result, + then: result.then.bind(result), + }; + }, + }), + insert: (table: unknown) => ({ + values: () => ({ + returning: () => { + expect(table).toBe(activityLog); + return Promise.resolve([{ id: "activity-1" }]); + }, + }), + }), + } as unknown as Db; +} + +/** + * The bus is emitted to fire-and-forget from `publishPluginDomainEvent`, so a + * turn of the macrotask queue is what makes the delivery observable. + */ +async function eventsFor(action: string, details: Record): Promise { + const bus = createPluginEventBus(); + setPluginEventBus(bus); + const received: PluginEvent[] = []; + bus.forPlugin("test-subscriber").subscribe("issue.interaction.created", async (event) => { + received.push(event); + }); + bus.forPlugin("test-subscriber").subscribe("issue.interaction.resolved", async (event) => { + received.push(event); + }); + + await logActivity(stubDb(), { + companyId, + actorType: "agent", + actorId: "agent-1", + agentId: "agent-1", + action, + entityType: "issue", + entityId: issueId, + details, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + return received; +} + +const CREATED_DETAILS = { + interactionId, + interactionKind: "request_confirmation", + interactionStatus: "pending", + continuationPolicy: "wake_assignee", + addresseeAgentId: null, + requestedResolverPolicy: "board_only", + effectiveResolverPolicy: "board_only", +}; + +describe("an agent's hand-off to a human reaches a plugin", () => { + it("delivers a created interaction as issue.interaction.created", async () => { + const received = await eventsFor("issue.thread_interaction_created", CREATED_DETAILS); + expect(received).toHaveLength(1); + expect(received[0].eventType).toBe("issue.interaction.created"); + }); + + it("carries enough to act on: the task, the kind, and that it is pending", async () => { + // A subscriber that cannot tell which task, or whether anyone still has to + // answer, cannot do anything with the event. + const [event] = await eventsFor("issue.thread_interaction_created", CREATED_DETAILS); + expect(event.entityType).toBe("issue"); + expect(event.entityId).toBe(issueId); + expect(event.companyId).toBe(companyId); + expect(event.payload).toMatchObject({ + interactionId, + interactionKind: "request_confirmation", + interactionStatus: "pending", + addresseeAgentId: null, + }); + }); + + it("keeps those fields through redaction", async () => { + // None of them look like a credential, and the redactor is name-driven — but + // it is the redactor that decides, so assert it rather than assume it. + const [event] = await eventsFor("issue.thread_interaction_created", CREATED_DETAILS); + expect(JSON.stringify(event.payload)).not.toContain("REDACTED"); + }); +}); + +describe("every way an interaction ends is one event", () => { + const endings = [ + ["issue.thread_interaction_accepted", "accepted"], + ["issue.thread_interaction_rejected", "rejected"], + ["issue.thread_interaction_answered", "answered"], + ["issue.thread_interaction_withdrawn", "cancelled"], + ["issue.thread_interaction_cancelled", "cancelled"], + ["issue.thread_interaction_expired", "expired"], + ] as const; + + for (const [action, status] of endings) { + it(`delivers ${action} as issue.interaction.resolved`, async () => { + const received = await eventsFor(action, { interactionId, interactionStatus: status }); + expect(received).toHaveLength(1); + expect(received[0].eventType).toBe("issue.interaction.resolved"); + expect(received[0].payload).toMatchObject({ interactionStatus: status }); + }); + } + + it("delivers a partial verdict submission as resolved, still marked pending", async () => { + // Collapsed into the same event on purpose; the status in the payload is + // what says whether anybody is off the hook. + const received = await eventsFor("issue.thread_interaction_item_verdicts_submitted", { + interactionId, + interactionStatus: "pending", + complete: false, + }); + expect(received).toHaveLength(1); + expect(received[0].eventType).toBe("issue.interaction.resolved"); + expect(received[0].payload).toMatchObject({ interactionStatus: "pending", complete: false }); + }); +}); + +describe("nothing else changed shape", () => { + it("still routes an approval decision to approval.decided", async () => { + const bus = createPluginEventBus(); + setPluginEventBus(bus); + const received: PluginEvent[] = []; + bus.forPlugin("test-subscriber").subscribe("approval.decided", async (event) => { + received.push(event); + }); + await logActivity(stubDb(), { + companyId, + actorType: "user", + actorId: "user-1", + action: "approval.approved", + entityType: "approval", + entityId: "00000000-0000-4000-8000-000000000009", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(received.map((event) => event.eventType)).toEqual(["approval.decided"]); + }); + + it("leaves an unmapped action off the bus entirely", async () => { + const bus = createPluginEventBus(); + setPluginEventBus(bus); + const received: PluginEvent[] = []; + bus.forPlugin("test-subscriber").subscribe("issue.interaction.created", async (event) => { + received.push(event); + }); + await logActivity(stubDb(), { + companyId, + actorType: "agent", + actorId: "agent-1", + action: "issue.thread_interaction_reminded", + entityType: "issue", + entityId: issueId, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(received).toEqual([]); + }); +}); diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index 4c75f0752730..d49ac13cc689 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -19,6 +19,26 @@ const ACTIVITY_ACTION_TO_PLUGIN_EVENT: Readonly> issue_document_updated: "issue.document.updated", issue_document_deleted: "issue.document.deleted", issue_blockers_updated: "issue.relations.updated", + // An issue-thread interaction is how an agent hands a task to a human: it + // cannot park the task as `blocked` and name a person as the unblock owner + // (that is a 403), so the interaction is the hand-off. Every way one ends + // collapses to a single `resolved` event, the same shape the three approval + // decisions already collapse to `approval.decided`: a subscriber that showed + // "waiting for you" only needs to know the wait is over, not which of the + // seven exits it took. The exit itself stays in `payload.interactionStatus`. + // + // `item_verdicts_submitted` is in the resolved set even though a partial + // submission leaves the interaction pending — `payload.interactionStatus` is + // authoritative, and splitting it out would give subscribers a second event + // name to learn for the same lifecycle. + issue_thread_interaction_created: "issue.interaction.created", + issue_thread_interaction_accepted: "issue.interaction.resolved", + issue_thread_interaction_rejected: "issue.interaction.resolved", + issue_thread_interaction_answered: "issue.interaction.resolved", + issue_thread_interaction_item_verdicts_submitted: "issue.interaction.resolved", + issue_thread_interaction_withdrawn: "issue.interaction.resolved", + issue_thread_interaction_cancelled: "issue.interaction.resolved", + issue_thread_interaction_expired: "issue.interaction.resolved", approval_approved: "approval.decided", approval_rejected: "approval.decided", approval_revision_requested: "approval.decided",