diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts index 3a09d586ada..ad975ffba98 100644 --- a/server/src/__tests__/agent-conversations.test.ts +++ b/server/src/__tests__/agent-conversations.test.ts @@ -1000,6 +1000,7 @@ describe("chat prompt policy", () => { expect(AGENT_CHAT_DIRECTIVE).toContain("do not look for others in package registries, repositories or the web"); expect(AGENT_CHAT_DIRECTIVE).toContain("The web app has no teams catalog page, so do not link one"); expect(AGENT_CHAT_DIRECTIVE).toContain("paperclipai teams install --company-id "); + expect(AGENT_CHAT_DIRECTIVE).toContain("When you recommend a team, name its roles (agentSlugs), every required skill (requiredSkills, by its ref) and every configuration input (envInputs)"); const prompt = buildPaperclipTaskMarkdown({ issue: { id: "chat", identifier: "RUN-1", title: "Chat", workMode: "ask", conversationAgentId: "agent" } }); expect(prompt).toContain("GET /api/teams/catalog"); }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 1255222a013..06607730ec7 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -2753,6 +2753,44 @@ describe("issue thread interaction routes", () => { expect(mockInteractionService.acceptInteraction).not.toHaveBeenCalled(); }); + it("names the addressee as the only resolver when an agent tries another person's human-only card", async () => { + mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "todo" })); + mockInteractionService.getForIssue.mockResolvedValueOnce({ + id: "interaction-for-riley", + kind: "request_confirmation", + status: "pending", + createdByAgentId: null, + createdByUserId: "user-alex", + addresseeUserId: "user-riley", + sourceRunId: null, + requestedResolverPolicy: "human_only", + effectiveResolverPolicy: "human_only", + payload: { version: 1, prompt: "Riley: approve?" }, + }); + const defaultWhere = mockDbSelectWhere.getMockImplementation()!; + // The addressee name lookup ends in .limit(1); the other reads keep their defaults. + mockDbSelectWhere.mockImplementation((() => ({ + ...defaultWhere(), + limit: () => Promise.resolve([{ name: "Riley" }]), + })) as unknown as typeof defaultWhere); + const app = await createApp({ type: "agent", agentId: ASSIGNEE_AGENT_ID, companyId: "company-1", runId: RUN_2 }); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-for-riley/accept") + .send({}); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ + code: "interaction_human_only", + details: { addresseeUserId: "user-riley", addresseeName: "Riley" }, + }); + expect(res.body.error).toContain("addressed to Riley."); + expect(res.body.error).toContain("Only Riley can accept or decline it"); + // /accept carries no verified replying user, so the advice stays neutral. + expect(res.body.error).not.toContain("do not ask the person asking"); + expect(mockInteractionService.acceptInteraction).not.toHaveBeenCalled(); + }); + it("keeps a same-requester sibling confirmation human-only", async () => { mockReviewTransition.value = { actorType: "agent", diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f703afa58cf..b250c86541d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -394,6 +394,7 @@ import { resolveIssueReviewRequester, } from "../services/issue-review-policy.js"; import { + addressedHumanOnlyDenialMessage, evaluateIssueThreadInteractionResolverAudience, issueThreadInteractionAttentionAgentAllowed, type IssueThreadInteractionResolverAudienceDecision, @@ -6010,6 +6011,16 @@ export function issueRoutes( return false; } + /** The addressee's display name, so a denial relayed in chat can name who resolves the card. */ + async function addresseeDisplayName(userId: string) { + try { + const [row] = await db.select({ name: authUsers.name }).from(authUsers).where(eq(authUsers.id, userId)).limit(1); + return typeof row?.name === "string" && row.name.trim() ? row.name.trim() : null; + } catch { + return null; + } + } + function denyIssueThreadInteractionResolution( res: Response, input: { @@ -6206,13 +6217,18 @@ export function issueRoutes( payload?.secretProposal !== undefined), }); if (!decision.allowed) { + const addresseeUserId = decision.code === "interaction_human_only" && typeof decision.details?.addresseeUserId === "string" + ? decision.details.addresseeUserId + : null; + const addresseeName = addresseeUserId ? await addresseeDisplayName(addresseeUserId) : null; return denyIssueThreadInteractionResolution(res, { status: decision.status, code: decision.code, - message: decision.message, + message: addresseeName ? addressedHumanOnlyDenialMessage(addresseeName, decision.details?.askerIsNotAddressee === true) : decision.message, details: { effectiveResolverPolicy: decision.effectiveResolverPolicy, ...(decision.details ?? {}), + ...(addresseeName ? { addresseeName } : {}), }, }); } diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index a176ab31741..e54f7d98945 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -119,7 +119,7 @@ When a reply lists many tasks, write each task identifier in full, such as RUN-1 A person's own display name and avatar belong to their account, and only they can change them in their profile settings at //company/settings/instance/profile. Agent credentials cannot edit a person's profile, and renaming an agent, a member record or the company does not change it. When the person asks to change their own name or avatar, link that page instead of attempting the change. -Ready-made teams are installable packages in Paperclip's teams catalog, not agents already in this company. When the person asks whether a ready-made team exists, or refers to one, list the catalog with GET /api/teams/catalog and choose by its descriptions and roles, since teams are not named after every kind of project. The catalog is the only source of ready-made teams: do not look for others in package registries, repositories or the web. A team chosen earlier in this chat may be recorded in its task context (get_task_context). POST /api/companies/{companyId}/teams/catalog/{catalogId}/preview shows what an install would add and creates no resources. Installing needs a board user or an agent with permission to create agents. The web app has no teams catalog page, so do not link one: name the team and its id, and when you cannot install, tell the person that a board user can run paperclipai teams install --company-id . After an install, link the installed agents and projects at their own pages. +Ready-made teams are installable packages in Paperclip's teams catalog, not agents already in this company. When the person asks whether a ready-made team exists, or refers to one, list the catalog with GET /api/teams/catalog and choose by its descriptions and roles, since teams are not named after every kind of project. When you recommend a team, name its roles (agentSlugs), every required skill (requiredSkills, by its ref) and every configuration input (envInputs), and say that a catalog listing does not mean the team's accounts are configured. The catalog is the only source of ready-made teams: do not look for others in package registries, repositories or the web. A team chosen earlier in this chat may be recorded in its task context (get_task_context). POST /api/companies/{companyId}/teams/catalog/{catalogId}/preview shows what an install would add and creates no resources. Installing needs a board user or an agent with permission to create agents. The web app has no teams catalog page, so do not link one: name the team and its id, and when you cannot install, tell the person that a board user can run paperclipai teams install --company-id . After an install, link the installed agents and projects at their own pages. This conversation and your credentials belong to one company. A task identifier whose prefix differs from this conversation's prefix belongs to another company or to none, and you cannot read it here, even when the person can open it themselves: a lookup that is not found does not mean the task does not exist. Do not search for it further, guess its content or copy anything from it. Say that this chat can only work in this company, and that to use that task the person can switch to its company and open a chat there. Link the task at //issues/, taking the prefix from the identifier the person gave; this is the one link that does not use this conversation's prefix. diff --git a/server/src/services/issue-thread-interaction-resolution.test.ts b/server/src/services/issue-thread-interaction-resolution.test.ts index 6693faf9ff8..3b61724dc89 100644 --- a/server/src/services/issue-thread-interaction-resolution.test.ts +++ b/server/src/services/issue-thread-interaction-resolution.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + addressedHumanOnlyDenialMessage, evaluateIssueThreadInteractionResolverAudience, issueThreadInteractionAttentionAgentAllowed, } from "./issue-thread-interaction-resolution.js"; @@ -173,6 +174,44 @@ describe("issue-thread interaction resolver audience", () => { })).toBe(false); }); + describe("human-only card addressed to someone else", () => { + const card = interaction({ createdByAgentId: null, createdByUserId: "alex", addresseeUserId: "riley", effectiveResolverPolicy: "human_only" }); + + it("tells the agent only the addressee resolves it and not to send the asker to the card", () => { + const decision = evaluateIssueThreadInteractionResolverAudience({ actor: { ...agent, relayedHumanUserId: "alex" }, interaction: card }); + expect(decision).toMatchObject({ allowed: false, status: 403, code: "interaction_human_only", details: { addresseeUserId: "riley", askerIsNotAddressee: true } }); + const message = decision.allowed ? "" : decision.message; + expect(message).toContain("This issue-thread interaction is human-only and addressed to"); + expect(message).toContain("Only the addressee can accept or decline it"); + expect(message).toContain("do not ask the person asking to accept or decline it"); + }); + + it("names the addressee once the route knows their display name", () => { + const message = addressedHumanOnlyDenialMessage("Riley", true); + expect(message).toContain("addressed to Riley."); + expect(message).toContain("Only Riley can accept or decline it"); + expect(message).toContain("the card waits on Riley"); + expect(message).not.toContain("addresseeUserId"); + }); + + it.each([ + ["no verified replying user", agent], + ["the addressee as the replying user", { ...agent, relayedHumanUserId: "riley" }], + ])("keeps the advice neutral with %s", (_name, actor) => { + const decision = evaluateIssueThreadInteractionResolverAudience({ actor, interaction: card }); + expect(decision).toMatchObject({ allowed: false, code: "interaction_human_only", details: { addresseeUserId: "riley", askerIsNotAddressee: false } }); + const message = decision.allowed ? "" : decision.message; + expect(message).toContain("Only the addressee can accept or decline it"); + expect(message).not.toContain("do not ask the person asking"); + }); + + it("keeps the plain human-only denial when the card has no addressee", () => { + const decision = evaluateIssueThreadInteractionResolverAudience({ actor: agent, + interaction: interaction({ effectiveResolverPolicy: "human_only" }) }); + expect(decision).toMatchObject({ allowed: false, code: "interaction_human_only", message: "This issue-thread interaction is human-only" }); + }); + }); + describe("relayed human decision", () => { const relay = { ...agent, relayedHumanUserId: "user-1" }; const humanOnly = (overrides: Record = {}) => diff --git a/server/src/services/issue-thread-interaction-resolution.ts b/server/src/services/issue-thread-interaction-resolution.ts index 4ee3cc9830f..4e6bf628256 100644 --- a/server/src/services/issue-thread-interaction-resolution.ts +++ b/server/src/services/issue-thread-interaction-resolution.ts @@ -183,6 +183,21 @@ function relayedHumanDecisionAllowed( && (!input.interaction.addresseeUserId || input.interaction.addresseeUserId === userId); } +/** + * Denial for a human-only card addressed to one person. Agents relay it in + * chat, so it names who resolves the card. Only when the replying user is + * known to be someone else does it tell the agent not to send them to the card. + */ +export function addressedHumanOnlyDenialMessage(addresseeName: string | null | undefined, askerIsNotAddressee: boolean) { + const name = addresseeName?.trim() || null; + const message = `This issue-thread interaction is human-only and addressed to ${name ?? "one person, its addressee (addresseeUserId)"}. ` + + `Only ${name ?? "the addressee"} can accept or decline it from the card; no agent can resolve it for them.`; + if (!askerIsNotAddressee) return message; + return `${message} The person asking is not the addressee, and their chat message does not count as the addressee's decision. ` + + `This denial is final. Tell them the card waits on ${name ?? "the addressee"}, name ${name ? "them" : "the addressee"} as the only one who can resolve it, ` + + "and do not ask the person asking to accept or decline it."; +} + /** * The single pure audience evaluator for issue-thread interaction resolution. * Resource access, run validation, containment, and current-target checks must @@ -285,6 +300,19 @@ export function evaluateIssueThreadInteractionResolverAudience( if (relayedHumanDecisionAllowed(input, persistedPolicy, additionalRestriction)) { return { allowed: true, effectiveResolverPolicy, reason: "allow_relayed_human" }; } + const addresseeUserId = input.interaction.addresseeUserId; + if (addresseeUserId && input.actor.type === "agent") { + // Only a verified replying user is known; /accept and /reject carry none. + const askerIsNotAddressee = Boolean(input.actor.relayedHumanUserId) && input.actor.relayedHumanUserId !== addresseeUserId; + return { + allowed: false, + effectiveResolverPolicy, + status: 403, + code: "interaction_human_only", + message: addressedHumanOnlyDenialMessage(null, askerIsNotAddressee), + details: { addresseeUserId, askerIsNotAddressee }, + }; + } return { allowed: false, effectiveResolverPolicy,