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",