diff --git a/cli/src/__tests__/company-delete.test.ts b/cli/src/__tests__/company-delete.test.ts index d45fc12022..54cc59a231 100644 --- a/cli/src/__tests__/company-delete.test.ts +++ b/cli/src/__tests__/company-delete.test.ts @@ -27,6 +27,7 @@ function makeCompany(overrides: Partial): Company { createdAt: new Date(), updatedAt: new Date(), ...overrides, + interactionResolverGovernance: overrides.interactionResolverGovernance ?? {}, }; } diff --git a/docs/api/issues.md b/docs/api/issues.md index e63e70c172..bebea9f76e 100644 --- a/docs/api/issues.md +++ b/docs/api/issues.md @@ -193,6 +193,7 @@ GET /api/issues/{issueId}/interactions POST /api/issues/{issueId}/interactions { "kind": "request_confirmation", + "resolverPolicy": "board_only", "idempotencyKey": "confirmation:{issueId}:plan:{revisionId}", "title": "Plan approval", "summary": "Waiting for the board/user to accept or request changes.", @@ -223,6 +224,12 @@ Supported `kind` values: - `suggest_tasks`: propose child issues for the board/user to accept or reject - `ask_user_questions`: ask structured questions and store selected answers - `request_confirmation`: ask the board/user to accept or reject a proposal +- `request_checkbox_confirmation`: ask for one accept/reject decision over selected option ids +- `request_item_verdicts`: collect approve/reject/defer verdicts per item + +`resolverPolicy: "board_only" | "board_or_agents"`. Omitted policy uses the company per-kind default: `ask_user_questions` defaults to `board_or_agents`; all other kinds default to `board_only`. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance`, keyed by kind, with optional `defaultPolicy` and `cap`. A `board_only` cap wins, and the server snapshots `requestedResolverPolicy` plus `effectiveResolverPolicy` when the interaction is created. + +`addresseeAgentId` optionally targets a same-company agent. The addressee is woken with `interaction_pending`, and only that agent or a board user may resolve the card; the creator cannot address itself, tool-action confirmations with an addressee return `400`, and all low-trust/watchdog/same-run restrictions remain. Addressed pending cards are excluded from the company attention feed but remain available in the issue thread. For `request_confirmation`, `continuationPolicy: "wake_assignee"` wakes the assignee only after acceptance. Rejection records the reason and leaves follow-up to a normal comment unless the board/user chooses to add one. @@ -232,9 +239,13 @@ For `request_confirmation`, `continuationPolicy: "wake_assignee"` wakes the assi POST /api/issues/{issueId}/interactions/{interactionId}/accept POST /api/issues/{issueId}/interactions/{interactionId}/reject POST /api/issues/{issueId}/interactions/{interactionId}/respond +POST /api/issues/{issueId}/interactions/{interactionId}/verdicts +POST /api/issues/{issueId}/interactions/{interactionId}/withdraw ``` -Board users resolve interactions from the UI. Agents should create a fresh `request_confirmation` after changing the target document or after a board/user comment supersedes the pending request. +Board users can resolve all interactions. Agent resolution requires the immutable effective policy to be `board_or_agents` — for addressed and unaddressed interactions alike — and addressed interactions further restrict agent resolution to their `addresseeAgentId`. Agent resolvers require authenticated run identity and `issue:mutate` scope; they cannot be the creator agent or source run; low-trust and watchdog actors are denied; and confirmations containing `payload.toolAction` are always board-only. Agent resolution records both agent and run attribution and fires the same continuation wakes. + +The creator agent or a board user may withdraw a pending interaction. Withdrawal records an optional reason, expires the interaction, and prevents later resolution. Low-trust and task-watchdog agent runs cannot withdraw interactions. ## Documents diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index f36f63bbef..5bd5f0626e 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -1104,6 +1104,8 @@ describe("sandbox callback bridge", () => { { method: "POST", path: "/api/issues/issue-1/interactions/inter-1/accept" }, { method: "POST", path: "/api/issues/issue-1/interactions/inter-1/reject" }, { method: "POST", path: "/api/issues/issue-1/interactions/inter-1/respond" }, + { method: "POST", path: "/api/issues/issue-1/interactions/inter-1/verdicts" }, + { method: "POST", path: "/api/issues/issue-1/interactions/inter-1/withdraw" }, { method: "POST", path: "/api/companies/co-1/issues" }, { method: "GET", path: "/api/approvals/ap-1" }, { method: "GET", path: "/api/approvals/ap-1/issues" }, diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index b21d8a1874..3014f1ed6d 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -73,10 +73,10 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa { method: "POST", path: /^\/api\/issues\/[^/]+\/work-products$/ }, { method: "PATCH", path: /^\/api\/work-products\/[^/]+$/ }, - // Issue-thread interactions (suggest tasks, ask questions, request confirmation) + // Issue-thread interactions (create, resolve, verdict, and withdraw) { method: "GET", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ }, { method: "POST", path: /^\/api\/issues\/[^/]+\/interactions$/ }, - { method: "POST", path: /^\/api\/issues\/[^/]+\/interactions\/[^/]+\/(?:accept|reject|respond)$/ }, + { method: "POST", path: /^\/api\/issues\/[^/]+\/interactions\/[^/]+\/(?:accept|reject|respond|verdicts|withdraw)$/ }, // Subtasks / delegation { method: "POST", path: /^\/api\/companies\/[^/]+\/issues$/ }, diff --git a/packages/db/src/migrations/0203_interaction_resolver_governance.sql b/packages/db/src/migrations/0203_interaction_resolver_governance.sql new file mode 100644 index 0000000000..25a81f315e --- /dev/null +++ b/packages/db/src/migrations/0203_interaction_resolver_governance.sql @@ -0,0 +1,8 @@ +ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "interaction_resolver_governance" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "requested_resolver_policy" text DEFAULT 'board_only' NOT NULL;--> statement-breakpoint +ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "effective_resolver_policy" text DEFAULT 'board_only' NOT NULL;--> statement-breakpoint +ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "resolved_by_run_id" uuid;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_resolved_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("resolved_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; diff --git a/packages/db/src/migrations/0204_interaction_addressee.sql b/packages/db/src/migrations/0204_interaction_addressee.sql new file mode 100644 index 0000000000..ddb177b992 --- /dev/null +++ b/packages/db/src/migrations/0204_interaction_addressee.sql @@ -0,0 +1,6 @@ +ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "addressee_agent_id" uuid;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_addressee_agent_id_agents_id_fk" FOREIGN KEY ("addressee_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_addressee_agent_idx" ON "issue_thread_interactions" USING btree ("addressee_agent_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index b7367aa77d..02a30fd31e 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1408,6 +1408,20 @@ "when": 1785702264747, "tag": "0202_eminent_marvel_zombies", "breakpoints": true + }, + { + "idx": 203, + "version": "7", + "when": 1785702264748, + "tag": "0203_interaction_resolver_governance", + "breakpoints": true + }, + { + "idx": 204, + "version": "7", + "when": 1785702264749, + "tag": "0204_interaction_addressee", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema/companies.ts b/packages/db/src/schema/companies.ts index c4c9c3ce97..3f0d0c9e61 100644 --- a/packages/db/src/schema/companies.ts +++ b/packages/db/src/schema/companies.ts @@ -1,4 +1,5 @@ -import { pgTable, uuid, text, integer, timestamp, boolean, uniqueIndex } from "drizzle-orm/pg-core"; +import type { InteractionResolverGovernance } from "@paperclipai/shared"; +import { pgTable, uuid, text, integer, timestamp, boolean, jsonb, uniqueIndex } from "drizzle-orm/pg-core"; export const companies = pgTable( "companies", @@ -20,6 +21,10 @@ export const companies = pgTable( requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents") .notNull() .default(false), + interactionResolverGovernance: jsonb("interaction_resolver_governance") + .$type() + .notNull() + .default({}), feedbackDataSharingEnabled: boolean("feedback_data_sharing_enabled") .notNull() .default(false), diff --git a/packages/db/src/schema/issue_thread_interactions.ts b/packages/db/src/schema/issue_thread_interactions.ts index 75895953d8..98f7d74a6f 100644 --- a/packages/db/src/schema/issue_thread_interactions.ts +++ b/packages/db/src/schema/issue_thread_interactions.ts @@ -1,5 +1,6 @@ import type { IssueThreadInteractionPayload, + IssueThreadInteractionResolverPolicy, IssueThreadInteractionResult, } from "@paperclipai/shared"; import { sql } from "drizzle-orm"; @@ -19,14 +20,24 @@ export const issueThreadInteractions = pgTable( kind: text("kind").notNull(), status: text("status").notNull().default("pending"), continuationPolicy: text("continuation_policy").notNull().default("wake_assignee"), + requestedResolverPolicy: text("requested_resolver_policy") + .$type() + .notNull() + .default("board_only"), + effectiveResolverPolicy: text("effective_resolver_policy") + .$type() + .notNull() + .default("board_only"), idempotencyKey: text("idempotency_key"), sourceCommentId: uuid("source_comment_id").references(() => issueComments.id, { onDelete: "set null" }), sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), title: text("title"), summary: text("summary"), createdByAgentId: uuid("created_by_agent_id").references(() => agents.id), + addresseeAgentId: uuid("addressee_agent_id").references(() => agents.id, { onDelete: "set null" }), createdByUserId: text("created_by_user_id"), resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id), + resolvedByRunId: uuid("resolved_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), resolvedByUserId: text("resolved_by_user_id"), payload: jsonb("payload").$type().notNull(), result: jsonb("result").$type(), @@ -50,5 +61,6 @@ export const issueThreadInteractions = pgTable( .on(table.companyId, table.issueId, table.idempotencyKey) .where(sql`${table.idempotencyKey} IS NOT NULL`), sourceCommentIdx: index("issue_thread_interactions_source_comment_idx").on(table.sourceCommentId), + addresseeAgentIdx: index("issue_thread_interactions_addressee_agent_idx").on(table.addresseeAgentId), }), ); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index a065aa3f8d..a44dd79cb1 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -262,6 +262,13 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [ ] as const; export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number]; +export const ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES = [ + "board_only", + "board_or_agents", +] as const; +export type IssueThreadInteractionResolverPolicy = + (typeof ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES)[number]; + export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200; export const REQUEST_ITEM_VERDICTS_ITEM_LIMIT = REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 407696eb4b..a1fa4312ef 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -267,6 +267,7 @@ export { ISSUE_COMMENT_PRESENTATION_DENSITIES, clampIssueRequestDepth, ISSUE_THREAD_INTERACTION_KINDS, + ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES, ISSUE_THREAD_INTERACTION_STATUSES, ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_ORIGIN_KINDS, @@ -451,6 +452,7 @@ export { type IssueCommentPresentationTone, type IssueCommentPresentationDensity, type IssueThreadInteractionKind, + type IssueThreadInteractionResolverPolicy, type IssueThreadInteractionStatus, type IssueThreadInteractionContinuationPolicy, REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT, @@ -607,6 +609,8 @@ export { export type { Company, + InteractionResolverGovernance, + InteractionResolverKindGovernance, GenerateSummarySlotRequest, GenerateSummarySlotResponse, GetSummarySlotResponse, @@ -1521,6 +1525,7 @@ export { export { createCompanySchema, + interactionResolverGovernanceSchema, updateCompanySchema, updateCompanyBrandingSchema, feedbackTargetTypeSchema, diff --git a/packages/shared/src/types/company.ts b/packages/shared/src/types/company.ts index 771c63f17f..ff316b2a8f 100644 --- a/packages/shared/src/types/company.ts +++ b/packages/shared/src/types/company.ts @@ -1,4 +1,18 @@ -import type { CompanyStatus, PauseReason } from "../constants.js"; +import type { + CompanyStatus, + IssueThreadInteractionKind, + IssueThreadInteractionResolverPolicy, + PauseReason, +} from "../constants.js"; + +export interface InteractionResolverKindGovernance { + defaultPolicy?: IssueThreadInteractionResolverPolicy; + cap?: IssueThreadInteractionResolverPolicy; +} + +export type InteractionResolverGovernance = Partial< + Record +>; export interface Company { id: string; @@ -14,6 +28,7 @@ export interface Company { attachmentMaxBytes: number; defaultResponsibleUserId: string | null; requireBoardApprovalForNewAgents: boolean; + interactionResolverGovernance: InteractionResolverGovernance; feedbackDataSharingEnabled: boolean; feedbackDataSharingConsentAt: Date | null; feedbackDataSharingConsentByUserId: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a851e31a14..3ad71b3379 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,4 +1,8 @@ -export type { Company } from "./company.js"; +export type { + Company, + InteractionResolverGovernance, + InteractionResolverKindGovernance, +} from "./company.js"; export type { GenerateSummarySlotRequest, GenerateSummarySlotResponse, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index bdeed97cde..83f6a4b6a1 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -25,6 +25,7 @@ import type { ModelProfileKey, IssueThreadInteractionContinuationPolicy, IssueThreadInteractionKind, + IssueThreadInteractionResolverPolicy, IssueThreadInteractionStatus, IssueStatus, } from "../constants.js"; @@ -982,6 +983,7 @@ export interface IssueThreadInteractionActorFields { createdByAgentId?: string | null; createdByUserId?: string | null; resolvedByAgentId?: string | null; + resolvedByRunId?: string | null; resolvedByUserId?: string | null; } @@ -1019,7 +1021,7 @@ export interface SuggestTasksResultCreatedTask { export interface SuggestTasksResult { version: 1; - outcome?: "withdrawn" | "issue_closed"; + outcome?: "withdrawn" | "issue_closed" | "addressee_deleted"; reason?: string | null; createdTasks?: SuggestTasksResultCreatedTask[]; skippedClientKeys?: string[]; @@ -1057,7 +1059,7 @@ export interface AskUserQuestionsAnswer { export interface AskUserQuestionsResult { version: 1; - outcome?: "withdrawn" | "issue_closed"; + outcome?: "withdrawn" | "issue_closed" | "addressee_deleted"; reason?: string | null; answers: AskUserQuestionsAnswer[]; cancelled?: true; @@ -1200,7 +1202,8 @@ export interface RequestConfirmationResult { | "superseded_by_newer_request" | "stale_target" | "withdrawn" - | "issue_closed"; + | "issue_closed" + | "addressee_deleted"; reason?: string | null; commentId?: string | null; supersededByInteractionId?: string | null; @@ -1233,7 +1236,7 @@ export interface RequestItemVerdictsResultItem { export interface RequestItemVerdictsResult { version: 1; - outcome: "resolved" | "superseded_by_comment" | "stale_target" | "cancelled" | "withdrawn" | "issue_closed"; + outcome: "resolved" | "superseded_by_comment" | "stale_target" | "cancelled" | "withdrawn" | "issue_closed" | "addressee_deleted"; reason?: string | null; complete: boolean; items: RequestItemVerdictsResultItem[]; @@ -1249,10 +1252,14 @@ export interface IssueThreadInteractionBase extends IssueThreadInteractionActorF idempotencyKey?: string | null; sourceCommentId?: string | null; sourceRunId?: string | null; + addresseeAgentId?: string | null; title?: string | null; summary?: string | null; status: IssueThreadInteractionStatus; continuationPolicy: IssueThreadInteractionContinuationPolicy; + resolverPolicy: IssueThreadInteractionResolverPolicy; + requestedResolverPolicy: IssueThreadInteractionResolverPolicy; + effectiveResolverPolicy: IssueThreadInteractionResolverPolicy; createdAt: Date | string; updatedAt: Date | string; resolvedAt?: Date | string | null; diff --git a/packages/shared/src/validators/company.ts b/packages/shared/src/validators/company.ts index 9f682edb2c..dc808ccc90 100644 --- a/packages/shared/src/validators/company.ts +++ b/packages/shared/src/validators/company.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { COMPANY_STATUSES, + ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES, MAX_COMPANY_ATTACHMENT_MAX_BYTES, } from "../constants.js"; @@ -13,6 +14,19 @@ const attachmentMaxBytesSchema = z .min(1) .max(MAX_COMPANY_ATTACHMENT_MAX_BYTES); +const interactionResolverKindGovernanceSchema = z.object({ + defaultPolicy: z.enum(ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES).optional(), + cap: z.enum(ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES).optional(), +}).strict(); + +export const interactionResolverGovernanceSchema = z.object({ + suggest_tasks: interactionResolverKindGovernanceSchema.optional(), + ask_user_questions: interactionResolverKindGovernanceSchema.optional(), + request_confirmation: interactionResolverKindGovernanceSchema.optional(), + request_checkbox_confirmation: interactionResolverKindGovernanceSchema.optional(), + request_item_verdicts: interactionResolverKindGovernanceSchema.optional(), +}).strict().default({}); + export const createCompanySchema = z.object({ name: z.string().min(1), description: z.string().optional().nullable(), @@ -29,6 +43,7 @@ export const updateCompanySchema = createCompanySchema status: z.enum(COMPANY_STATUSES).optional(), spentMonthlyCents: z.number().int().nonnegative().optional(), requireBoardApprovalForNewAgents: z.boolean().optional(), + interactionResolverGovernance: interactionResolverGovernanceSchema.optional(), feedbackDataSharingEnabled: z.boolean().optional(), feedbackDataSharingConsentAt: z.coerce.date().nullable().optional(), feedbackDataSharingConsentByUserId: z.string().min(1).nullable().optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index da5d5ca7a9..4050254f7b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -59,6 +59,7 @@ export { export { createCompanySchema, + interactionResolverGovernanceSchema, updateCompanySchema, updateCompanyBrandingSchema, type CreateCompany, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 3d912ec03a..43b0fe8af3 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -25,6 +25,7 @@ import { ISSUE_STATUSES, ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_THREAD_INTERACTION_KINDS, + ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES, ISSUE_THREAD_INTERACTION_STATUSES, ISSUE_WATCHDOG_DISCOVERY_KINDS, MODEL_PROFILE_KEYS, @@ -658,6 +659,7 @@ export type AddIssueComment = z.infer; export const issueThreadInteractionStatusSchema = z.enum(ISSUE_THREAD_INTERACTION_STATUSES); export const issueThreadInteractionKindSchema = z.enum(ISSUE_THREAD_INTERACTION_KINDS); +export const issueThreadInteractionResolverPolicySchema = z.enum(ISSUE_THREAD_INTERACTION_RESOLVER_POLICIES); export const issueThreadInteractionContinuationPolicySchema = z.enum( ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ); @@ -724,7 +726,7 @@ export const suggestTasksResultCreatedTaskSchema = z.object({ export const suggestTasksResultSchema = z.object({ version: z.literal(1), - outcome: z.enum(["withdrawn", "issue_closed"]).optional(), + outcome: z.enum(["withdrawn", "issue_closed", "addressee_deleted"]).optional(), reason: z.string().trim().max(4000).nullable().optional(), createdTasks: z.array(suggestTasksResultCreatedTaskSchema).max(50).optional(), skippedClientKeys: z.array(z.string().trim().min(1).max(120)).max(50).optional(), @@ -786,7 +788,7 @@ export const askUserQuestionsAnswerSchema = z.object({ export const askUserQuestionsResultSchema = z.object({ version: z.literal(1), - outcome: z.enum(["withdrawn", "issue_closed"]).optional(), + outcome: z.enum(["withdrawn", "issue_closed", "addressee_deleted"]).optional(), reason: z.string().trim().max(4000).nullable().optional(), answers: z.array(askUserQuestionsAnswerSchema).max(20), cancelled: z.literal(true).optional(), @@ -993,6 +995,7 @@ export const requestConfirmationResultSchema = z.object({ "stale_target", "withdrawn", "issue_closed", + "addressee_deleted", ]), reason: z.string().trim().max(4000).nullable().optional(), commentId: z.string().uuid().nullable().optional(), @@ -1116,7 +1119,7 @@ export const requestItemVerdictsResultItemSchema = z.object({ export const requestItemVerdictsResultSchema = z.object({ version: z.literal(1), - outcome: z.enum(["resolved", "superseded_by_comment", "stale_target", "cancelled", "withdrawn", "issue_closed"]), + outcome: z.enum(["resolved", "superseded_by_comment", "stale_target", "cancelled", "withdrawn", "issue_closed", "addressee_deleted"]), reason: z.string().trim().max(4000).nullable().optional(), complete: z.boolean(), items: z.array(requestItemVerdictsResultItemSchema) @@ -1137,8 +1140,14 @@ export const requestItemVerdictsResultSchema = z.object({ } }); +const createIssueThreadInteractionCommon = { + resolverPolicy: issueThreadInteractionResolverPolicySchema.optional(), + addresseeAgentId: z.string().uuid().nullable().optional(), +}; + export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ z.object({ + ...createIssueThreadInteractionCommon, kind: z.literal("suggest_tasks"), idempotencyKey: z.string().trim().max(255).nullable().optional(), sourceCommentId: z.string().uuid().nullable().optional(), @@ -1149,6 +1158,7 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ payload: suggestTasksPayloadSchema, }), z.object({ + ...createIssueThreadInteractionCommon, kind: z.literal("ask_user_questions"), idempotencyKey: z.string().trim().max(255).nullable().optional(), sourceCommentId: z.string().uuid().nullable().optional(), @@ -1159,6 +1169,7 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ payload: askUserQuestionsPayloadSchema, }), z.object({ + ...createIssueThreadInteractionCommon, kind: z.literal("request_confirmation"), idempotencyKey: z.string().trim().max(255).nullable().optional(), sourceCommentId: z.string().uuid().nullable().optional(), @@ -1169,6 +1180,7 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ payload: requestConfirmationPayloadSchema, }), z.object({ + ...createIssueThreadInteractionCommon, kind: z.literal("request_checkbox_confirmation"), idempotencyKey: z.string().trim().max(255).nullable().optional(), sourceCommentId: z.string().uuid().nullable().optional(), @@ -1179,6 +1191,7 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ payload: requestCheckboxConfirmationPayloadSchema, }), z.object({ + ...createIssueThreadInteractionCommon, kind: z.literal("request_item_verdicts"), idempotencyKey: z.string().trim().max(255).nullable().optional(), sourceCommentId: z.string().uuid().nullable().optional(), diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index 286f4e6bc4..07a10fc9e2 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -42,6 +42,7 @@ import { import { errorHandler } from "../middleware/index.js"; import { attentionRoutes } from "../routes/attention.js"; import { attentionService } from "../services/attention.js"; +import { agentService } from "../services/agents.js"; import { ROUTABLE_BLOCKED_ROLLOUT_AT } from "../services/routable-blocked.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -336,6 +337,19 @@ describeEmbeddedPostgres("attention service", () => { createdAt: new Date("2026-07-09T12:03:00.000Z"), updatedAt: new Date("2026-07-09T12:03:00.000Z"), }, + { + id: randomUUID(), + companyId, + issueId: interactionIssueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + addresseeAgentId: reviewerId, + title: "Ask the reviewer privately", + payload: { version: 1, questions: [] }, + createdAt: new Date("2026-07-09T12:03:15.000Z"), + updatedAt: new Date("2026-07-09T12:03:15.000Z"), + }, { id: randomUUID(), companyId, @@ -630,6 +644,102 @@ describeEmbeddedPostgres("attention service", () => { }); }); + it("returns addressed interactions to board attention after addressee pause or termination", async () => { + const { companyId, reviewerId } = await seedCompany("ATF"); + const pausedReviewerId = randomUUID(); + const terminatedReviewerId = randomUUID(); + await db.insert(agents).values([ + { + id: pausedReviewerId, + companyId, + name: "Paused Reviewer", + role: "qa", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: terminatedReviewerId, + companyId, + name: "Terminated Reviewer", + role: "qa", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + const issueId = await insertIssue({ + companyId, + identifier: "ATF-1", + title: "Needs a decision", + status: "in_progress", + }); + await db.insert(issueThreadInteractions).values([ + { + id: randomUUID(), + companyId, + issueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Board question", + payload: { version: 1, questions: [] }, + }, + { + id: randomUUID(), + companyId, + issueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + addresseeAgentId: reviewerId, + title: "Active reviewer question", + payload: { version: 1, questions: [] }, + }, + { + id: randomUUID(), + companyId, + issueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + addresseeAgentId: pausedReviewerId, + title: "Paused reviewer question", + payload: { version: 1, questions: [] }, + }, + { + id: randomUUID(), + companyId, + issueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + addresseeAgentId: terminatedReviewerId, + title: "Terminated reviewer question", + payload: { version: 1, questions: [] }, + }, + ]); + + await agentService(db).pause(pausedReviewerId); + await agentService(db).terminate(terminatedReviewerId); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + const interactionTitles = feed.items + .filter((item) => item.sourceKind === "issue_thread_interaction") + .map((item) => item.subject.title); + + expect(interactionTitles).toEqual(expect.arrayContaining([ + "Board question", + "Paused reviewer question", + "Terminated reviewer question", + ])); + expect(interactionTitles).not.toContain("Active reviewer question"); + }); + it("suppresses failed-run attention after a newer run for the same issue", async () => { const { companyId, workerId } = await seedCompany("ATN"); const issueId = await insertIssue({ diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 4560fb43e0..87247c06cb 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -113,6 +113,7 @@ function registerModuleMocks() { }), issueThreadInteractionService: () => ({ listForIssue: vi.fn(async () => []), + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), }), diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 62b1f55d5c..0ef6059ffa 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -68,6 +68,7 @@ const mockStorageService = vi.hoisted(() => ({ deleteObject: vi.fn(), })); const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(async () => []), diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts index 03ac6dc47a..0d426f7296 100644 --- a/server/src/__tests__/issue-comment-cancel-routes.test.ts +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -36,6 +36,7 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ listCompanyIds: vi.fn(async () => ["company-1"]), })); const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 3384c2735f..ab7d80a2b4 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -70,6 +70,7 @@ const mockRoutineService = vi.hoisted(() => ({ syncRunStatusForIssue: vi.fn(async () => undefined), })); const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index f26c07b73a..71764b8830 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -73,6 +73,7 @@ vi.mock("../services/index.js", () => ({ }), issueThreadInteractionService: () => ({ listForIssue: vi.fn(async () => []), + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), }), diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index 7b528f084c..4f93179af3 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -46,6 +46,7 @@ const mockRoutineService = vi.hoisted(() => ({ syncRunStatusForIssue: vi.fn(async () => undefined), })); const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 214f231c93..a60c2c40cf 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -47,6 +47,7 @@ const mockDb = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), listForIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), })); diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 727b36f7f2..ff0ac6e98a 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -50,6 +50,7 @@ const mockRoutineService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index 55a105ee24..ad9524f422 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -79,6 +79,7 @@ function registerModuleMocks() { }), issueThreadInteractionService: () => ({ listForIssue: vi.fn(async () => []), + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), }), diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 9476303e82..a1089fc59e 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -3,6 +3,7 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111"; +const UNRELATED_AGENT_ID = "33333333-3333-4333-8333-333333333333"; const CREATED_AGENT_ID = "22222222-2222-4222-8222-222222222222"; const mockIssueService = vi.hoisted(() => ({ @@ -28,6 +29,8 @@ const mockInteractionService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), })); +const mockResolveTaskWatchdogMutationScope = vi.hoisted(() => vi.fn(async () => ({ kind: "none" }))); +const mockResolveCoreTrustPreset = vi.hoisted(() => vi.fn(() => ({ kind: "standard" }))); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ @@ -52,6 +55,17 @@ vi.mock("../telemetry.js", () => ({ getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); +vi.mock("../services/task-watchdog-scope.js", () => ({ + TASK_WATCHDOG_ORIGIN_KIND: "task_watchdog", + resolveTaskWatchdogMutationScope: mockResolveTaskWatchdogMutationScope, + taskWatchdogScopeAllowsIssueMutation: vi.fn(async (_db, scope) => scope), +})); + +vi.mock("../services/trust-preset-resolver.js", () => ({ + LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH: 100, + resolveCoreTrustPreset: mockResolveCoreTrustPreset, +})); + function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ companyService: () => ({ @@ -186,15 +200,22 @@ describe.sequential("issue thread interaction routes", () => { vi.doUnmock("../services/index.js"); registerModuleMocks(); vi.clearAllMocks(); + mockResolveTaskWatchdogMutationScope.mockResolvedValue({ kind: "none" }); + mockResolveCoreTrustPreset.mockReturnValue({ kind: "standard" }); mockIssueService.getById.mockResolvedValue(createIssue()); mockInteractionService.listForIssue.mockResolvedValue([]); mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValue([]); mockInteractionService.expirePendingInteractionsForTerminalIssue.mockResolvedValue([]); mockInteractionService.getForIssue.mockResolvedValue({ id: "interaction-withdraw", + kind: "ask_user_questions", createdByAgentId: CREATED_AGENT_ID, + sourceRunId: "run-1", + requestedResolverPolicy: "board_or_agents", + effectiveResolverPolicy: "board_or_agents", continuationPolicy: "wake_assignee", status: "pending", + payload: { version: 1, questions: [] }, }); mockInteractionService.withdrawInteraction.mockResolvedValue({ id: "interaction-withdraw", @@ -457,6 +478,91 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("wakes the addressed agent when an interaction is created", async () => { + mockInteractionService.create.mockResolvedValueOnce({ + id: "interaction-addressed", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + addresseeAgentId: ASSIGNEE_AGENT_ID, + requestedResolverPolicy: "board_only", + effectiveResolverPolicy: "board_only", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: null, + payload: { version: 1, questions: [] }, + result: null, + createdAt: "2026-07-25T12:00:00.000Z", + updatedAt: "2026-07-25T12:00:00.000Z", + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions") + .send({ + kind: "ask_user_questions", + addresseeAgentId: ASSIGNEE_AGENT_ID, + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Which scope?", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }); + + expect(res.status).toBe(201); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "interaction_pending", + idempotencyKey: "interaction-pending:interaction-addressed", + payload: expect.objectContaining({ + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + interactionId: "interaction-addressed", + }), + contextSnapshot: expect.objectContaining({ wakeReason: "interaction_pending" }), + }), + ); + }); + + it("returns 400 for agent-addressed tool-action confirmations", async () => { + const app = await createApp(); + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions") + .send({ + kind: "request_confirmation", + addresseeAgentId: ASSIGNEE_AGENT_ID, + payload: { + version: 1, + prompt: "Run the tool?", + toolAction: { + version: 1, + actionRequestId: "11111111-1111-4111-8111-111111111111", + invocationId: "22222222-2222-4222-8222-222222222222", + toolName: "send_email", + toolDisplayName: "Send email", + connectionId: "33333333-3333-4333-8333-333333333333", + applicationId: "44444444-4444-4444-8444-444444444444", + appDisplayName: "Gmail", + risk: "write", + previewMarkdown: "Send an email to the reviewed recipient.", + argumentsSummaryJson: '{"to":"recipient@example.com"}', + argumentsHash: "reviewed-arguments-hash", + expiresAt: "2026-07-25T16:00:00.000Z", + }, + }, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("cannot be addressed"); + expect(mockInteractionService.create).not.toHaveBeenCalled(); + }); + it("accepts suggested tasks and wakes created assignees plus the current assignee", async () => { const app = await createApp(); @@ -644,6 +750,35 @@ describe.sequential("issue thread interaction routes", () => { expect(mockInteractionService.withdrawInteraction).not.toHaveBeenCalled(); }); + it("rejects withdrawal by watchdog-scoped runs", async () => { + mockResolveTaskWatchdogMutationScope.mockResolvedValueOnce({ + kind: "watchdog", + watchdogId: "watchdog-1", + companyId: "company-1", + watchedIssueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + watchdogIssueId: null, + stopFingerprint: "stop-1", + }); + const app = await createApp({ type: "agent", agentId: ASSIGNEE_AGENT_ID, companyId: "company-1", runId: "run-watchdog" }); + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-withdraw/withdraw") + .send({}); + expect(res.status).toBe(403); + expect(res.body.error).toContain("Task-watchdog"); + expect(mockInteractionService.withdrawInteraction).not.toHaveBeenCalled(); + }); + + it("rejects withdrawal by low-trust actors", async () => { + mockResolveCoreTrustPreset.mockReturnValueOnce({ kind: "low_trust_review" }); + const app = await createApp({ type: "agent", agentId: ASSIGNEE_AGENT_ID, companyId: "company-1", runId: "run-low-trust" }); + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-withdraw/withdraw") + .send({}); + expect(res.status).toBe(403); + expect(res.body.error).toContain("Low-trust"); + expect(mockInteractionService.withdrawInteraction).not.toHaveBeenCalled(); + }); + it("cancels question interactions and emits a continuation wake", async () => { const app = await createApp(); @@ -1369,4 +1504,221 @@ describe.sequential("issue thread interaction routes", () => { }, ); }); + + it("allows a different in-scope agent run to respond when policy permits", async () => { + mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "todo" })); + 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-2/respond") + .send({ answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }); + + expect(res.status).toBe(200); + expect(mockInteractionService.answerQuestions).toHaveBeenCalledWith( + expect.anything(), + "interaction-2", + expect.anything(), + expect.objectContaining({ agentId: ASSIGNEE_AGENT_ID, runId: "run-2", userId: null }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ idempotencyKey: "interaction:interaction-2:answered" }), + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + actorType: "agent", + agentId: ASSIGNEE_AGENT_ID, + runId: "run-2", + details: expect.objectContaining({ resolutionActorKind: "agent" }), + })); + }); + + it("allows only the addressed agent or board to resolve an addressed interaction", async () => { + const addressed = { + id: "interaction-addressed", + kind: "ask_user_questions", + createdByAgentId: CREATED_AGENT_ID, + addresseeAgentId: ASSIGNEE_AGENT_ID, + sourceRunId: "run-1", + requestedResolverPolicy: "board_or_agents", + effectiveResolverPolicy: "board_or_agents", + payload: { version: 1, questions: [] }, + }; + mockInteractionService.getForIssue + .mockResolvedValueOnce(addressed) + .mockResolvedValueOnce(addressed) + .mockResolvedValueOnce(addressed); + mockIssueService.getById + .mockResolvedValueOnce(createIssue({ status: "todo" })) + .mockResolvedValueOnce(createIssue({ status: "todo" })) + .mockResolvedValueOnce(createIssue({ status: "todo" })); + + const addresseeApp = await createApp({ + type: "agent", + agentId: ASSIGNEE_AGENT_ID, + companyId: "company-1", + runId: "run-2", + }); + const addressee = await request(addresseeApp) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-addressed/respond") + .send({ answers: [] }); + expect(addressee.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ idempotencyKey: "interaction:interaction-2:answered" }), + ); + + const unrelatedApp = await createApp({ + type: "agent", + agentId: UNRELATED_AGENT_ID, + companyId: "company-1", + runId: "run-3", + }); + const unrelated = await request(unrelatedApp) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-addressed/respond") + .send({ answers: [] }); + expect(unrelated.status).toBe(403); + expect(unrelated.body.error).toContain("addressed agent"); + + const boardApp = await createApp(); + const board = await request(boardApp) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-addressed/respond") + .send({ answers: [] }); + expect(board.status).toBe(200); + }); + + it("blocks creator-agent self-resolution", async () => { + mockIssueService.getById.mockResolvedValueOnce(createIssue({ + status: "todo", + assigneeAgentId: CREATED_AGENT_ID, + })); + const app = await createApp({ + type: "agent", + agentId: CREATED_AGENT_ID, + companyId: "company-1", + runId: "run-9", + }); + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond") + .send({ answers: [] }); + expect(res.status).toBe(403); + expect(res.body.error).toContain("created"); + expect(mockInteractionService.answerQuestions).not.toHaveBeenCalled(); + }); + + it("blocks same-run resolution and requires a resolver run id", async () => { + mockInteractionService.getForIssue.mockResolvedValueOnce({ + id: "interaction-2", + kind: "ask_user_questions", + createdByAgentId: CREATED_AGENT_ID, + sourceRunId: "run-2", + requestedResolverPolicy: "board_or_agents", + effectiveResolverPolicy: "board_or_agents", + payload: { version: 1, questions: [] }, + }); + mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "todo" })); + const sameRunApp = await createApp({ + type: "agent", + agentId: ASSIGNEE_AGENT_ID, + companyId: "company-1", + runId: "run-2", + }); + const sameRun = await request(sameRunApp) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond") + .send({ answers: [] }); + expect(sameRun.status).toBe(403); + expect(sameRun.body.error).toContain("same run"); + + mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "todo" })); + const missingRunApp = await createApp({ + type: "agent", + agentId: ASSIGNEE_AGENT_ID, + companyId: "company-1", + }); + const missingRun = await request(missingRunApp) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond") + .send({ answers: [] }); + expect(missingRun.status).toBe(401); + }); + + it("blocks board-only and tool-action interactions for agents", async () => { + mockInteractionService.getForIssue + .mockResolvedValueOnce({ + id: "interaction-1", + kind: "request_confirmation", + createdByAgentId: CREATED_AGENT_ID, + sourceRunId: "run-1", + requestedResolverPolicy: "board_or_agents", + effectiveResolverPolicy: "board_only", + payload: { version: 1, prompt: "Proceed?" }, + }) + .mockResolvedValueOnce({ + id: "interaction-tool", + kind: "request_confirmation", + createdByAgentId: CREATED_AGENT_ID, + sourceRunId: "run-1", + requestedResolverPolicy: "board_or_agents", + effectiveResolverPolicy: "board_or_agents", + payload: { version: 1, prompt: "Run?", toolAction: { actionRequestId: "action-1" } }, + }); + mockIssueService.getById + .mockResolvedValueOnce(createIssue({ status: "todo" })) + .mockResolvedValueOnce(createIssue({ status: "todo" })); + const app = await createApp({ + type: "agent", + agentId: ASSIGNEE_AGENT_ID, + companyId: "company-1", + runId: "run-2", + }); + + const capped = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-1/accept") + .send({}); + expect(capped.status).toBe(403); + expect(capped.body.error).toContain("board-only"); + + const toolAction = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-tool/accept") + .send({}); + expect(toolAction.status).toBe(403); + expect(toolAction.body.error).toContain("Tool-action"); + }); + + it("explicitly blocks watchdog-scoped and low-trust resolver agents", async () => { + mockIssueService.getById.mockResolvedValue(createIssue({ status: "todo" })); + const actor = { + type: "agent", + agentId: ASSIGNEE_AGENT_ID, + companyId: "company-1", + runId: "run-2", + }; + + mockResolveTaskWatchdogMutationScope.mockResolvedValueOnce({ + kind: "watchdog", + watchdogId: "watchdog-1", + companyId: "company-1", + watchedIssueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + watchdogIssueId: null, + stopFingerprint: "stop-1", + }); + const watchdogApp = await createApp(actor); + const watchdog = await request(watchdogApp) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond") + .send({ answers: [] }); + expect(watchdog.status).toBe(403); + expect(watchdog.body.error).toContain("watchdog"); + + mockResolveCoreTrustPreset.mockReturnValueOnce({ kind: "low_trust_review" }); + const lowTrustApp = await createApp(actor); + const lowTrust = await request(lowTrustApp) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond") + .send({ answers: [] }); + expect(lowTrust.status).toBe(403); + expect(lowTrust.body.error).toContain("Low-trust"); + expect(mockInteractionService.answerQuestions).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 2083b1e426..7773770910 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -27,6 +27,7 @@ import { import { instanceSettingsService } from "../services/instance-settings.js"; import { issueService } from "../services/issues.js"; import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; +import { agentService } from "../services/agents.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -98,6 +99,307 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { return { companyId, goalId, issueId }; } + it("persists addressees without allowing them to bypass board-only governance", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Agent-addressed interaction"); + const creatorAgentId = randomUUID(); + const addresseeAgentId = randomUUID(); + const unrelatedAgentId = randomUUID(); + const addresseeRunId = randomUUID(); + const unrelatedRunId = randomUUID(); + const agentRows = [ + { id: creatorAgentId, name: "Creator" }, + { id: addresseeAgentId, name: "Addressee" }, + { id: unrelatedAgentId, name: "Unrelated" }, + ].map((agent) => ({ + ...agent, + companyId, + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + })); + await db.insert(agents).values(agentRows); + await db.insert(heartbeatRuns).values([ + { + id: addresseeRunId, + companyId, + agentId: addresseeAgentId, + invocationSource: "manual", + status: "running", + startedAt: new Date("2026-07-25T12:00:00.000Z"), + }, + { + id: unrelatedRunId, + companyId, + agentId: unrelatedAgentId, + invocationSource: "manual", + status: "running", + startedAt: new Date("2026-07-25T12:01:00.000Z"), + }, + ]); + + const input = { + kind: "ask_user_questions" as const, + resolverPolicy: "board_or_agents" as const, + addresseeAgentId, + continuationPolicy: "wake_assignee" as const, + payload: { + version: 1 as const, + questions: [{ + id: "scope", + prompt: "Which scope?", + selectionMode: "single" as const, + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }; + const created = await interactionsSvc.create( + { id: issueId, companyId }, + input, + { agentId: creatorAgentId }, + ); + expect(created).toMatchObject({ + addresseeAgentId, + requestedResolverPolicy: "board_or_agents", + effectiveResolverPolicy: "board_or_agents", + }); + + const answered = await interactionsSvc.answerQuestions( + { id: issueId, companyId }, + created.id, + { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }, + { agentId: addresseeAgentId, runId: addresseeRunId }, + ); + expect(answered).toMatchObject({ + status: "answered", + addresseeAgentId, + resolvedByAgentId: addresseeAgentId, + resolvedByRunId: addresseeRunId, + }); + + const second = await interactionsSvc.create( + { id: issueId, companyId }, + { ...input, idempotencyKey: "addressed:second" }, + { agentId: creatorAgentId }, + ); + await expect(interactionsSvc.answerQuestions( + { id: issueId, companyId }, + second.id, + { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }, + { agentId: unrelatedAgentId, runId: unrelatedRunId }, + )).rejects.toMatchObject({ + status: 403, + message: expect.stringContaining("addressed agent"), + }); + + const boardOnly = await interactionsSvc.create( + { id: issueId, companyId }, + { + ...input, + resolverPolicy: "board_only", + idempotencyKey: "addressed:board-only", + }, + { agentId: creatorAgentId }, + ); + await expect(interactionsSvc.answerQuestions( + { id: issueId, companyId }, + boardOnly.id, + { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }, + { agentId: addresseeAgentId, runId: addresseeRunId }, + )).rejects.toMatchObject({ + status: 403, + message: expect.stringContaining("board-only"), + }); + + await expect(interactionsSvc.create( + { id: issueId, companyId }, + { ...input, addresseeAgentId: creatorAgentId }, + { agentId: creatorAgentId }, + )).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining("themselves"), + }); + }); + + it("cancels addressed interactions before deleting the addressee", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Deleted interaction addressee"); + const creatorAgentId = randomUUID(); + const addresseeAgentId = randomUUID(); + const unrelatedAgentId = randomUUID(); + const unrelatedRunId = randomUUID(); + await db.insert(agents).values([ + { id: creatorAgentId, name: "Creator" }, + { id: addresseeAgentId, name: "Addressee" }, + { id: unrelatedAgentId, name: "Unrelated" }, + ].map((agent) => ({ + ...agent, + companyId, + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }))); + await db.insert(heartbeatRuns).values({ + id: unrelatedRunId, + companyId, + agentId: unrelatedAgentId, + invocationSource: "manual", + status: "running", + startedAt: new Date("2026-07-25T12:02:00.000Z"), + }); + + const created = await interactionsSvc.create( + { id: issueId, companyId }, + { + kind: "ask_user_questions", + resolverPolicy: "board_or_agents", + addresseeAgentId, + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Which scope?", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, + { agentId: creatorAgentId }, + ); + + await agentService(db).remove(addresseeAgentId); + + const cancelled = await interactionsSvc.getById(created.id); + expect(cancelled).toMatchObject({ + status: "cancelled", + addresseeAgentId: null, + resolvedByAgentId: null, + resolvedByRunId: null, + resolvedByUserId: null, + result: { + version: 1, + outcome: "addressee_deleted", + reason: "Cancelled because the addressed agent was deleted", + }, + }); + await expect(interactionsSvc.answerQuestions( + { id: issueId, companyId }, + created.id, + { answers: [{ questionId: "scope", optionIds: ["phase-1"] }] }, + { agentId: unrelatedAgentId, runId: unrelatedRunId }, + )).rejects.toMatchObject({ + status: 409, + message: "Interaction has already been resolved", + }); + }); + + it.each(["paused", "pending_approval", "terminated"])( + "rejects %s interaction addressees", + async (status) => { + const { companyId, issueId } = await seedConfirmationIssue(`Reject ${status} addressee`); + const creatorAgentId = randomUUID(); + const addresseeAgentId = randomUUID(); + await db.insert(agents).values([ + { + id: creatorAgentId, + companyId, + name: "Creator", + role: "engineer", + status: "active", + }, + { + id: addresseeAgentId, + companyId, + name: "Unavailable addressee", + role: "engineer", + status, + }, + ]); + + await expect(interactionsSvc.create( + { id: issueId, companyId }, + { + kind: "ask_user_questions", + addresseeAgentId, + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Which scope?", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, + { agentId: creatorAgentId }, + )).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining("invokable agent"), + details: expect.objectContaining({ reason: status }), + }); + }, + ); + + it("rejects interaction addressees with an invalid reporting chain", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Reject uninvokable addressee chain"); + const creatorAgentId = randomUUID(); + const managerAgentId = randomUUID(); + const addresseeAgentId = randomUUID(); + await db.insert(agents).values([ + { + id: creatorAgentId, + companyId, + name: "Creator", + role: "engineer", + status: "active", + }, + { + id: managerAgentId, + companyId, + name: "Terminated manager", + role: "manager", + status: "terminated", + }, + { + id: addresseeAgentId, + companyId, + name: "Unavailable addressee", + role: "engineer", + status: "active", + reportsTo: managerAgentId, + }, + ]); + + await expect(interactionsSvc.create( + { id: issueId, companyId }, + { + kind: "ask_user_questions", + addresseeAgentId, + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Which scope?", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + }, + { agentId: creatorAgentId }, + )).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining("invokable agent"), + details: expect.objectContaining({ + reason: "manager_terminated", + managerId: managerAgentId, + }), + }); + }); + it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => { const companyId = randomUUID(); const goalId = randomUUID(); diff --git a/server/src/__tests__/issue-thread-interactions-telemetry.test.ts b/server/src/__tests__/issue-thread-interactions-telemetry.test.ts index 0fe45ac507..1a5807614f 100644 --- a/server/src/__tests__/issue-thread-interactions-telemetry.test.ts +++ b/server/src/__tests__/issue-thread-interactions-telemetry.test.ts @@ -8,6 +8,7 @@ import { documentRevisions, documents, goals, + heartbeatRuns, issueComments, issueDocuments, issueThreadInteractions, @@ -58,6 +59,7 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => { await db.delete(documents); await db.delete(issues); await db.delete(goals); + await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(companies); }); @@ -237,12 +239,20 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => { it("emits rejected confirmation telemetry and omits creator_agent_role for user-created interactions", async () => { const { companyId, issueId } = await seedIssue("Reject confirmation telemetry"); const resolverAgentId = await seedAgent(companyId, "SecurityEngineer"); + const resolverRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: resolverRunId, + companyId, + agentId: resolverAgentId, + status: "running", + }); const created = await interactionsSvc.create({ id: issueId, companyId, }, { kind: "request_confirmation", + resolverPolicy: "board_or_agents", payload: { version: 1, prompt: "Approve this?", @@ -258,6 +268,7 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => { reason: "Needs edits before approval.", }, { agentId: resolverAgentId, + runId: resolverRunId, }); const dimensions = lastInteractionResolvedDimensions(); diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 4fb46c0dc3..9157b6d49e 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -25,6 +25,7 @@ const mockHeartbeatService = vi.hoisted(() => ({ cancelRun: vi.fn(async () => null), })); const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expirePendingInteractionsForTerminalIssue: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index c7fedd40f7..dde802657f 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -188,6 +188,9 @@ describe("openapi routes", () => { }, }); expect(res.body.paths["/api/companies/{companyId}/folders"].post.responses["201"]).toBeDefined(); + expect( + res.body.paths["/api/issues/{id}/interactions/{interactionId}/withdraw"].post.summary, + ).toBe("Withdraw a pending issue thread interaction"); expect(res.body.paths["/api/companies/{companyId}/folders/items/move"].post.summary).toBe( "Move an item into or out of a folder", ); diff --git a/server/src/__tests__/paperclip-skill-utils.test.ts b/server/src/__tests__/paperclip-skill-utils.test.ts index 898866dd38..26b11cb428 100644 --- a/server/src/__tests__/paperclip-skill-utils.test.ts +++ b/server/src/__tests__/paperclip-skill-utils.test.ts @@ -64,6 +64,22 @@ describe("paperclip skill utils", () => { await expect(fs.access(path.resolve("scripts/paperclip-upload-artifact.sh"))).rejects.toThrow(); }); + it("documents governed agent interaction resolution invariants", async () => { + const apiReference = await fs.readFile(path.resolve("skills/paperclip/references/api-reference.md"), "utf8"); + const issueDocs = await fs.readFile(path.resolve("docs/api/issues.md"), "utf8"); + for (const body of [apiReference, issueDocs]) { + expect(body).toContain('resolverPolicy: "board_only" | "board_or_agents"'); + expect(body).toContain("requestedResolverPolicy"); + expect(body).toContain("effectiveResolverPolicy"); + expect(body).toContain("toolAction"); + expect(body).toContain("watchdog"); + expect(body).toContain("low-trust"); + expect(body).toContain("addresseeAgentId"); + expect(body).toContain("interaction_pending"); + expect(body).toContain("attention feed"); + } + }); + it("uses the authoritative PATCH response to confirm monitor scheduling", async () => { const skillBody = await fs.readFile(path.resolve("skills/paperclip/SKILL.md"), "utf8"); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5542662d19..8d8f8e6e01 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -137,7 +137,7 @@ import { } from "../services/task-watchdog-scope.js"; import type { TaskWatchdogServiceDeps, taskWatchdogService } from "../services/task-watchdogs.js"; import { logger } from "../middleware/logger.js"; -import { conflict, forbidden, HttpError, notFound, unauthorized, unprocessable } from "../errors.js"; +import { badRequest, conflict, forbidden, HttpError, notFound, unauthorized, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; import { assertNoAgentHostWorkspaceCommandMutation, @@ -1963,7 +1963,7 @@ function queueResolvedInteractionContinuationWakeup(input: { ...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}), mutation: "interaction", }, - idempotencyKey: input.idempotencyKey ?? null, + idempotencyKey: input.idempotencyKey ?? `interaction:${input.interaction.id}:${input.interaction.status}`, requestedByActorType: input.actor.actorType, requestedByActorId: input.actor.actorId, contextSnapshot: { @@ -3745,7 +3745,7 @@ export function issueRoutes( return false; } - async function rejectAgentIssueThreadInteractionResolution( + async function rejectTaskWatchdogInteractionMutation( req: Request, res: Response, issue: { @@ -3755,13 +3755,73 @@ export function issueRoutes( }, ) { if (req.actor.type !== "agent") return false; - if ( - req.actor.runId && - !(await assertTaskWatchdogIssueMutationAllowed(req, res, issue, { allowWatchdogIssue: false })) - ) { + const scope = await resolveTaskWatchdogMutationScope(db, req.actor); + if (scope.kind === "none") return false; + const result = await taskWatchdogScopeAllowsIssueMutation(db, scope, issue); + if (result.kind === "invalid") { + res.status(403).json({ + error: result.detail, + details: { + issueId: issue.id, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], + }, + }); return true; } - res.status(403).json({ error: "Agent actors cannot resolve issue-thread interactions through this board-only route" }); + res.status(403).json({ error: "Task-watchdog runs cannot mutate issue-thread interactions" }); + return true; + } + + async function assertIssueThreadInteractionResolutionAllowed( + req: Request, + res: Response, + issue: Parameters[2], + interaction: { + createdByAgentId?: string | null; + sourceRunId?: string | null; + effectiveResolverPolicy: string; + addresseeAgentId?: string | null; + kind: string; + payload?: unknown; + }, + ) { + if (req.actor.type !== "agent") { + assertBoard(req); + return true; + } + const actorAgentId = req.actor.agentId; + const runId = requireAgentRunId(req, res); + if (!actorAgentId || !runId) return false; + const watchdogScope = await resolveTaskWatchdogMutationScope(db, req.actor); + if (watchdogScope.kind !== "none") { + res.status(403).json({ error: "Task-watchdog runs cannot resolve issue-thread interactions" }); + return false; + } + if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return false; + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return false; + if (interaction.effectiveResolverPolicy !== "board_or_agents") { + res.status(403).json({ error: "This issue-thread interaction is board-only" }); + return false; + } + if (interaction.addresseeAgentId && interaction.addresseeAgentId !== actorAgentId) { + res.status(403).json({ error: "Only the addressed agent or a board user may resolve this issue-thread interaction" }); + return false; + } + if (interaction.createdByAgentId === actorAgentId) { + res.status(403).json({ error: "Agents cannot resolve interactions they created" }); + return false; + } + if (interaction.sourceRunId === runId) { + res.status(403).json({ error: "Agents cannot resolve interactions created by the same run" }); + return false; + } + const payload = interaction.payload && typeof interaction.payload === "object" + ? interaction.payload as { toolAction?: unknown } + : null; + if (interaction.kind === "request_confirmation" && payload?.toolAction !== undefined) { + res.status(403).json({ error: "Tool-action confirmations are always board-only" }); + return false; + } return true; } @@ -9099,6 +9159,16 @@ export function issueRoutes( const becameTerminal = !["done", "cancelled"].includes(existing.status) && ["done", "cancelled"].includes(issue.status); if (becameTerminal) { + const expiredInteractions = await issueThreadInteractionService(db).expirePendingInteractionsForTerminalIssue(issue, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.status_transition.issue_closed", + }); await destroyReusableSandboxLeasesForTerminalIssue(issue); } if (becameTerminal && issue.parentId) { @@ -9452,6 +9522,13 @@ export function issueRoutes( const actor = getActorInfo(req); const agentSourceRunId = req.actor.type === "agent" ? requireAgentRunId(req, res) : null; if (req.actor.type === "agent" && !agentSourceRunId) return; + if ( + req.body.kind === "request_confirmation" + && req.body.addresseeAgentId + && req.body.payload?.toolAction !== undefined + ) { + throw badRequest("Tool-action confirmations cannot be addressed to agents"); + } if (req.body.kind === "request_confirmation" && req.body.payload?.toolAction !== undefined) { throw unprocessable("payload.toolAction is server-owned metadata and cannot be supplied when creating an interaction"); } @@ -9479,9 +9556,46 @@ export function issueRoutes( interactionKind: interaction.kind, interactionStatus: interaction.status, continuationPolicy: interaction.continuationPolicy, + addresseeAgentId: interaction.addresseeAgentId ?? null, + requestedResolverPolicy: interaction.requestedResolverPolicy, + effectiveResolverPolicy: interaction.effectiveResolverPolicy, }, }); + if (interaction.addresseeAgentId) { + void heartbeat.wakeup(interaction.addresseeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "interaction_pending", + payload: { + issueId: issue.id, + interactionId: interaction.id, + interactionKind: interaction.kind, + sourceCommentId: interaction.sourceCommentId ?? null, + sourceRunId: interaction.sourceRunId ?? null, + mutation: "interaction", + }, + idempotencyKey: `interaction-pending:${interaction.id}`, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: issue.id, + taskId: issue.id, + interactionId: interaction.id, + interactionKind: interaction.kind, + sourceCommentId: interaction.sourceCommentId ?? null, + sourceRunId: interaction.sourceRunId ?? null, + wakeReason: "interaction_pending", + source: "issue.interaction.created", + }, + }).catch((err) => logger.warn({ + err, + issueId: issue.id, + interactionId: interaction.id, + agentId: interaction.addresseeAgentId, + }, "failed to wake addressee on issue interaction creation")); + } + res.status(201).json(interaction); }); @@ -9493,12 +9607,15 @@ export function issueRoutes( const interactionId = req.params.interactionId as string; const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; - if (await rejectAgentIssueThreadInteractionResolution(req, res, issue)) return; - assertBoard(req); + if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return; + const interactionSvc = issueThreadInteractionService(db); + const current = await interactionSvc.getForIssue(issue, interactionId); + if (!(await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current))) return; const actor = getActorInfo(req); - const { interaction, createdIssues, continuationIssue } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, { + const { interaction, createdIssues, continuationIssue } = await interactionSvc.acceptInteraction(issue, interactionId, req.body, { agentId: actor.agentId, + runId: actor.runId, userId: actor.actorType === "user" ? actor.actorId : null, }); const toolAction = interaction.payload && typeof interaction.payload === "object" @@ -9558,6 +9675,9 @@ export function issueRoutes( interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, + resolutionActorKind: actor.actorType, + requestedResolverPolicy: interaction.requestedResolverPolicy, + effectiveResolverPolicy: interaction.effectiveResolverPolicy, createdTaskCount: interaction.kind === "suggest_tasks" ? (interaction.result?.createdTasks?.length ?? 0) @@ -9638,12 +9758,15 @@ export function issueRoutes( const interactionId = req.params.interactionId as string; const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; - if (await rejectAgentIssueThreadInteractionResolution(req, res, issue)) return; - assertBoard(req); + if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return; + const interactionSvc = issueThreadInteractionService(db); + const current = await interactionSvc.getForIssue(issue, interactionId); + if (!(await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current))) return; const actor = getActorInfo(req); - const interaction = await issueThreadInteractionService(db).rejectInteraction(issue, interactionId, req.body, { + const interaction = await interactionSvc.rejectInteraction(issue, interactionId, req.body, { agentId: actor.agentId, + runId: actor.runId, userId: actor.actorType === "user" ? actor.actorId : null, }); @@ -9663,6 +9786,9 @@ export function issueRoutes( interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, + resolutionActorKind: actor.actorType, + requestedResolverPolicy: interaction.requestedResolverPolicy, + effectiveResolverPolicy: interaction.effectiveResolverPolicy, rejectionReason: interaction.kind === "suggest_tasks" ? (interaction.result?.rejectionReason ?? null) @@ -9692,12 +9818,15 @@ export function issueRoutes( const interactionId = req.params.interactionId as string; const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; - if (await rejectAgentIssueThreadInteractionResolution(req, res, issue)) return; - assertBoard(req); + if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return; + const interactionSvc = issueThreadInteractionService(db); + const current = await interactionSvc.getForIssue(issue, interactionId); + if (!(await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current))) return; const actor = getActorInfo(req); - const interaction = await issueThreadInteractionService(db).answerQuestions(issue, interactionId, req.body, { + const interaction = await interactionSvc.answerQuestions(issue, interactionId, req.body, { agentId: actor.agentId, + runId: actor.runId, userId: actor.actorType === "user" ? actor.actorId : null, }); @@ -9715,6 +9844,9 @@ export function issueRoutes( interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, + resolutionActorKind: actor.actorType, + requestedResolverPolicy: interaction.requestedResolverPolicy, + effectiveResolverPolicy: interaction.effectiveResolverPolicy, answeredQuestionCount: interaction.kind === "ask_user_questions" ? (interaction.result?.answers?.length ?? 0) @@ -9742,16 +9874,19 @@ export function issueRoutes( const interactionId = req.params.interactionId as string; const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; - if (await rejectAgentIssueThreadInteractionResolution(req, res, issue)) return; - assertBoard(req); + if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return; + const interactionSvc = issueThreadInteractionService(db); + const current = await interactionSvc.getForIssue(issue, interactionId); + if (!(await assertIssueThreadInteractionResolutionAllowed(req, res, issue, current))) return; const actor = getActorInfo(req); - const { interaction, newlyResolvedItemIds } = await issueThreadInteractionService(db).submitItemVerdicts( + const { interaction, newlyResolvedItemIds } = await interactionSvc.submitItemVerdicts( issue, interactionId, req.body, { agentId: actor.agentId, + runId: actor.runId, userId: actor.actorType === "user" ? actor.actorId : null, }, ); @@ -9772,6 +9907,9 @@ export function issueRoutes( interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, + resolutionActorKind: actor.actorType, + requestedResolverPolicy: interaction.requestedResolverPolicy, + effectiveResolverPolicy: interaction.effectiveResolverPolicy, submittedVerdictCount: Array.isArray(req.body?.verdicts) ? req.body.verdicts.length : 0, newlyResolvedItemCount: newlyResolvedItemIds.length, newlyResolvedItemIds, @@ -9809,6 +9947,7 @@ export function issueRoutes( const interactionId = req.params.interactionId as string; const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; + if (await rejectTaskWatchdogInteractionMutation(req, res, issue)) return; const interactionSvc = issueThreadInteractionService(db); const current = await interactionSvc.getForIssue(issue, interactionId); @@ -9858,7 +9997,10 @@ export function issueRoutes( const interactionId = req.params.interactionId as string; const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; - if (await rejectAgentIssueThreadInteractionResolution(req, res, issue)) return; + if (req.actor.type === "agent") { + res.status(403).json({ error: "Agent actors cannot cancel issue-thread interactions through this board-only route" }); + return; + } assertBoard(req); const actor = getActorInfo(req); @@ -10707,6 +10849,16 @@ export function issueRoutes( !["done", "cancelled"].includes(issueBeforeCommentDecision.status) && ["done", "cancelled"].includes(currentIssue.status); if (becameTerminal) { + const expiredInteractions = await issueThreadInteractionService(db).expirePendingInteractionsForTerminalIssue(currentIssue, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + await logExpiredRequestConfirmations({ + issue: currentIssue, + interactions: expiredInteractions, + actor, + source: "issue.status_transition.issue_closed", + }); await destroyReusableSandboxLeasesForTerminalIssue(currentIssue); } if (becameTerminal && currentIssue.parentId) { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index b8701b6724..aad5b33bf1 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -145,6 +145,7 @@ import { rejectIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, submitIssueThreadInteractionVerdictsSchema, + withdrawIssueThreadInteractionSchema, // Auth / profile updateCurrentUserProfileSchema, // Company portability (legacy routes) @@ -180,7 +181,6 @@ import { createAcceptedPlanDecompositionSchema, resolveIssueRecoveryActionSchema, cancelIssueThreadInteractionSchema, - withdrawIssueThreadInteractionSchema, // Secret provider configs and remote import createSecretProviderConfigSchema, updateSecretProviderConfigSchema, @@ -816,6 +816,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/issues/{id}/interactions/{interactionId}/accept", "POST /api/issues/{id}/interactions/{interactionId}/reject", "POST /api/issues/{id}/interactions/{interactionId}/respond", + "POST /api/issues/{id}/interactions/{interactionId}/withdraw", "GET /api/companies/{companyId}/tools/gallery", "POST /api/companies/{companyId}/tools/apps/connect", "POST /api/companies/{companyId}/tools/apps/{connectionId}/finish", @@ -4348,6 +4349,18 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/withdraw", + tags: ["issues"], + summary: "Withdraw a pending issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(withdrawIssueThreadInteractionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + registry.registerPath({ method: "post", path: "/api/issues/{id}/children", diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 51546b911c..2c643ea480 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -34,6 +34,7 @@ import { builtInAgentMarkersEqual, readBuiltInAgentMarker, } from "./built-in-agent-metadata.js"; +import { issueThreadInteractionService } from "./issue-thread-interactions.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -741,6 +742,13 @@ export function agentService(db: Db) { } return db.transaction(async (tx) => { + await tx + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.id, id)) + .for("update"); + await issueThreadInteractionService(tx as unknown as Db) + .cancelPendingForDeletedAddressee(existing.companyId, id); await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id)); await tx .update(issues) diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index f43729b7f4..1f3749bcbb 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -54,6 +54,7 @@ import { } from "./issues.js"; import { parseIssueExecutionState } from "./issue-execution-policy.js"; import { isProspectiveBlockedTransition } from "./routable-blocked.js"; +import { evaluateAgentInvokability, type AgentOrgRow } from "./agent-invokability.js"; import { decisionQueueService } from "./decision-queues.js"; import { decisionRetentionService, @@ -1117,6 +1118,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions title: issueThreadInteractions.title, summary: issueThreadInteractions.summary, payload: issueThreadInteractions.payload, + addresseeAgentId: issueThreadInteractions.addresseeAgentId, createdByAgentId: issueThreadInteractions.createdByAgentId, createdAt: issueThreadInteractions.createdAt, updatedAt: issueThreadInteractions.updatedAt, @@ -1127,7 +1129,24 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]), )) .orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id)); - const visibleInteractionRows = collapsePendingConfirmationsToNewest(interactionRows); + const companyAgentRows: AgentOrgRow[] = interactionRows.some((row) => row.addresseeAgentId !== null) + ? await db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + reportsTo: agents.reportsTo, + status: agents.status, + }) + .from(agents) + .where(eq(agents.companyId, companyId)) + : []; + const companyAgentMap = new Map(companyAgentRows.map((agent) => [agent.id, agent])); + const boardInteractionRows = interactionRows.filter((row) => + row.addresseeAgentId === null || + !evaluateAgentInvokability(companyAgentMap.get(row.addresseeAgentId), companyAgentRows).invokable + ); + const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows); const [interactionIssueMap, interactionImageMap, interactionPlanDocumentMap] = await Promise.all([ issueSummaryMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)), issueImageMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)), diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 95626af307..c3d03ebfcd 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -139,6 +139,7 @@ export function companyService(db: Db) { attachmentMaxBytes: companies.attachmentMaxBytes, defaultResponsibleUserId: companies.defaultResponsibleUserId, requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents, + interactionResolverGovernance: companies.interactionResolverGovernance, feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled, feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt, feedbackDataSharingConsentByUserId: companies.feedbackDataSharingConsentByUserId, diff --git a/server/src/services/issue-thread-interactions.test.ts b/server/src/services/issue-thread-interactions.test.ts index 27d132867b..0c00964ffa 100644 --- a/server/src/services/issue-thread-interactions.test.ts +++ b/server/src/services/issue-thread-interactions.test.ts @@ -85,6 +85,41 @@ describe("issueThreadInteractionService", () => { vi.clearAllMocks(); }); + it.each([ + ["ask_user_questions", undefined, {}, "board_or_agents", "board_or_agents"], + ["suggest_tasks", undefined, {}, "board_only", "board_only"], + ["request_confirmation", "board_or_agents", {}, "board_or_agents", "board_or_agents"], + ["request_checkbox_confirmation", undefined, { request_checkbox_confirmation: { defaultPolicy: "board_or_agents" } }, "board_or_agents", "board_or_agents"], + ["request_item_verdicts", "board_or_agents", { request_item_verdicts: { cap: "board_only" } }, "board_or_agents", "board_only"], + ] as const)( + "resolves %s requested/default/cap policy snapshots", + async (kind, requested, governance, expectedRequested, expectedEffective) => { + const { resolveInteractionPolicy } = await import("./issue-thread-interactions.js"); + expect(resolveInteractionPolicy({ + kind, + requested, + governance, + hasToolAction: false, + })).toEqual({ + requestedResolverPolicy: expectedRequested, + effectiveResolverPolicy: expectedEffective, + }); + }, + ); + + it("always clamps tool-action confirmations to board-only", async () => { + const { resolveInteractionPolicy } = await import("./issue-thread-interactions.js"); + expect(resolveInteractionPolicy({ + kind: "request_confirmation", + requested: "board_or_agents", + governance: { request_confirmation: { defaultPolicy: "board_or_agents", cap: "board_or_agents" } }, + hasToolAction: true, + })).toEqual({ + requestedResolverPolicy: "board_or_agents", + effectiveResolverPolicy: "board_only", + }); + }); + it("create reuses an existing interaction for the same idempotency key", async () => { const { issueThreadInteractionService } = await import("./issue-thread-interactions.js"); @@ -95,6 +130,8 @@ describe("issueThreadInteractionService", () => { kind: "suggest_tasks", status: "pending", continuationPolicy: "wake_assignee", + requestedResolverPolicy: "board_only", + effectiveResolverPolicy: "board_only", idempotencyKey: "run-1:suggest", sourceCommentId: null, sourceRunId: "22222222-2222-4222-8222-222222222222", diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index c37fbb1800..dd862d9121 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -3,6 +3,7 @@ import { and, asc, desc, eq, inArray, isNotNull, isNull, ne } from "drizzle-orm" import type { Db } from "@paperclipai/db"; import { agents, + companies, documents, heartbeatRuns, issueComments, @@ -18,7 +19,10 @@ import type { AskUserQuestionsInteraction, CancelIssueThreadInteraction, CreateIssueThreadInteraction, + InteractionResolverGovernance, IssueThreadInteraction, + IssueThreadInteractionKind, + IssueThreadInteractionResolverPolicy, RequestCheckboxConfirmationInteraction, RequestConfirmationInteraction, RequestConfirmationTarget, @@ -51,12 +55,14 @@ import { withdrawIssueThreadInteractionSchema, } from "@paperclipai/shared"; import { z } from "zod"; -import { conflict, notFound, unprocessable } from "../errors.js"; +import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { getTelemetryClient } from "../telemetry.js"; +import { evaluateAgentInvokabilityFromDb } from "./agent-invokability.js"; import { issueService, runWorkspaceIsFinalized } from "./issues.js"; type InteractionActor = { agentId?: string | null; + runId?: string | null; userId?: string | null; }; @@ -79,6 +85,56 @@ type ResolvedInteractionResult = { type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect; type IssueTouchDb = Pick; +const DEFAULT_RESOLVER_POLICY_BY_KIND: Record = { + suggest_tasks: "board_only", + ask_user_questions: "board_or_agents", + request_confirmation: "board_only", + request_checkbox_confirmation: "board_only", + request_item_verdicts: "board_only", +}; + +export function resolveInteractionPolicy(args: { + kind: IssueThreadInteractionKind; + requested?: IssueThreadInteractionResolverPolicy; + governance: InteractionResolverGovernance; + hasToolAction: boolean; +}) { + const kindGovernance = args.governance[args.kind]; + const requestedResolverPolicy = args.requested + ?? kindGovernance?.defaultPolicy + ?? DEFAULT_RESOLVER_POLICY_BY_KIND[args.kind]; + const effectiveResolverPolicy = args.hasToolAction || kindGovernance?.cap === "board_only" + ? "board_only" + : requestedResolverPolicy; + return { requestedResolverPolicy, effectiveResolverPolicy } as const; +} + +function assertAgentResolutionAllowed(current: IssueThreadInteractionRow, actor: InteractionActor) { + if (!actor.agentId) return; + if (!actor.runId) throw forbidden("Agent run id required to resolve an issue-thread interaction"); + if (current.effectiveResolverPolicy !== "board_or_agents") { + throw forbidden("This issue-thread interaction is board-only"); + } + if (current.addresseeAgentId && current.addresseeAgentId !== actor.agentId) { + throw forbidden("Only the addressed agent or a board user may resolve this issue-thread interaction"); + } + if (current.createdByAgentId === actor.agentId) { + throw forbidden("Agents cannot resolve interactions they created"); + } + if (current.sourceRunId && current.sourceRunId === actor.runId) { + throw forbidden("Agents cannot resolve interactions created by the same run"); + } + if ( + current.kind === "request_confirmation" + && current.payload + && typeof current.payload === "object" + && "toolAction" in current.payload + && current.payload.toolAction !== undefined + ) { + throw forbidden("Tool-action confirmations are always board-only"); + } +} + type IssueResolutionContext = { id: string; companyId: string; @@ -140,6 +196,8 @@ function isEquivalentCreateRequest( ) { return ( row.kind === input.kind + && row.requestedResolverPolicy === input.resolverPolicy + && (row.addresseeAgentId ?? null) === (input.addresseeAgentId ?? null) && row.continuationPolicy === input.continuationPolicy && (row.idempotencyKey ?? null) === (input.idempotencyKey ?? null) && (row.sourceCommentId ?? null) === (input.sourceCommentId ?? null) @@ -184,8 +242,12 @@ function hydrateInteraction( const base = { ...row, idempotencyKey: row.idempotencyKey ?? null, + addresseeAgentId: row.addresseeAgentId ?? null, status: row.status as IssueThreadInteraction["status"], continuationPolicy: row.continuationPolicy as IssueThreadInteraction["continuationPolicy"], + resolverPolicy: row.requestedResolverPolicy, + requestedResolverPolicy: row.requestedResolverPolicy, + effectiveResolverPolicy: row.effectiveResolverPolicy, }; switch (row.kind) { @@ -358,7 +420,7 @@ function buildSupersededByNewerRequestResult(replacementInteractionId: string) { function buildAdministrativeOutcomeResult( row: IssueThreadInteractionRow, - outcome: "withdrawn" | "issue_closed", + outcome: "withdrawn" | "issue_closed" | "addressee_deleted", reason: string | null = null, ) { if (row.kind === "ask_user_questions") { @@ -936,6 +998,7 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: { ...buildStaleTargetResult(args.row, target), }, resolvedByAgentId: args.actor.agentId ?? null, + resolvedByRunId: args.actor.runId ?? null, resolvedByUserId: args.actor.userId ?? null, resolvedAt: now, updatedAt: now, @@ -1077,6 +1140,7 @@ export function issueThreadInteractionService(db: Db) { ...(selectedOptionIds ? { selectedOptionIds } : {}), }, resolvedByAgentId: args.actor.agentId ?? null, + resolvedByRunId: args.actor.runId ?? null, resolvedByUserId: args.actor.userId ?? null, resolvedAt: now, updatedAt: now, @@ -1174,6 +1238,7 @@ export function issueThreadInteractionService(db: Db) { reason: reason || null, }, resolvedByAgentId: args.actor.agentId ?? null, + resolvedByRunId: args.actor.runId ?? null, resolvedByUserId: args.actor.userId ?? null, resolvedAt: now, updatedAt: now, @@ -1215,6 +1280,50 @@ export function issueThreadInteractionService(db: Db) { return row ? hydrateInteraction(row) : null; }, + cancelPendingForDeletedAddressee: async (companyId: string, addresseeAgentId: string) => { + const rows = await db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, companyId), + eq(issueThreadInteractions.addresseeAgentId, addresseeAgentId), + eq(issueThreadInteractions.status, "pending"), + )); + if (rows.length === 0) return []; + + const now = new Date(); + const cancelled: IssueThreadInteraction[] = []; + for (const row of rows) { + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "cancelled", + result: buildAdministrativeOutcomeResult( + row, + "addressee_deleted", + "Cancelled because the addressed agent was deleted", + ), + resolvedByAgentId: null, + resolvedByRunId: null, + resolvedByUserId: null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + if (updated) cancelled.push(hydrateInteraction(updated)); + } + + for (const issueId of new Set(cancelled.map((interaction) => interaction.issueId))) { + await touchIssue(db, issueId); + } + await emitResolvedInteractionsTelemetry(db, cancelled); + return cancelled; + }, + sweepSupersededPendingRequestConfirmations: async () => { const rows = await db .select() @@ -1298,17 +1407,59 @@ export function issueThreadInteractionService(db: Db) { actor: InteractionActor, ) => { const data = normalizeCreateInteractionInput(createIssueThreadInteractionSchema.parse(input)); + const governance = await db + .select({ interactionResolverGovernance: companies.interactionResolverGovernance }) + .from(companies) + .where(eq(companies.id, issue.companyId)) + .then((rows) => rows[0]?.interactionResolverGovernance ?? {}); + const policy = resolveInteractionPolicy({ + kind: data.kind, + requested: data.resolverPolicy, + governance, + hasToolAction: data.kind === "request_confirmation" && data.payload.toolAction !== undefined, + }); + const normalizedData = { ...data, resolverPolicy: policy.requestedResolverPolicy }; - if (data.idempotencyKey) { + if (normalizedData.addresseeAgentId) { + if (normalizedData.addresseeAgentId === actor.agentId) { + throw unprocessable("Agents cannot address issue-thread interactions to themselves"); + } + if (normalizedData.kind === "request_confirmation" && normalizedData.payload.toolAction !== undefined) { + throw unprocessable("Tool-action confirmations cannot be addressed to agents"); + } + const addressee = await db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + reportsTo: agents.reportsTo, + status: agents.status, + }) + .from(agents) + .where(eq(agents.id, normalizedData.addresseeAgentId)) + .then((rows) => rows[0] ?? null); + if (!addressee || addressee.companyId !== issue.companyId) { + throw unprocessable("addresseeAgentId must belong to the same company"); + } + const invokability = await evaluateAgentInvokabilityFromDb(db, addressee); + if (!invokability.invokable) { + throw unprocessable("addresseeAgentId must reference an invokable agent", { + reason: invokability.reason, + ...invokability.details, + }); + } + } + + if (normalizedData.idempotencyKey) { const existing = await getIdempotentInteraction({ issueId: issue.id, companyId: issue.companyId, - idempotencyKey: data.idempotencyKey, + idempotencyKey: normalizedData.idempotencyKey, }); if (existing) { - if (!isEquivalentCreateRequest(existing, data, actor)) { + if (!isEquivalentCreateRequest(existing, normalizedData, actor)) { throw conflict("Interaction idempotency key already exists for a different request", { - idempotencyKey: data.idempotencyKey, + idempotencyKey: normalizedData.idempotencyKey, }); } return hydrateInteraction(existing); @@ -1379,12 +1530,15 @@ export function issueThreadInteractionService(db: Db) { kind: data.kind, status: "pending", continuationPolicy: data.continuationPolicy, + requestedResolverPolicy: policy.requestedResolverPolicy, + effectiveResolverPolicy: policy.effectiveResolverPolicy, idempotencyKey: data.idempotencyKey ?? null, sourceCommentId: data.sourceCommentId ?? null, sourceRunId: data.sourceRunId ?? null, title: data.title ?? null, summary: data.summary ?? null, createdByAgentId: actor.agentId ?? null, + addresseeAgentId: data.addresseeAgentId ?? null, createdByUserId: actor.userId ?? null, payload: data.payload, }) @@ -1427,18 +1581,18 @@ export function issueThreadInteractionService(db: Db) { created = result.row; superseded = result.supersededRows; } catch (error) { - if (!data.idempotencyKey || !isIssueThreadInteractionIdempotencyConflict(error)) { + if (!normalizedData.idempotencyKey || !isIssueThreadInteractionIdempotencyConflict(error)) { throw error; } const existing = await getIdempotentInteraction({ issueId: issue.id, companyId: issue.companyId, - idempotencyKey: data.idempotencyKey, + idempotencyKey: normalizedData.idempotencyKey, }); if (!existing) throw error; - if (!isEquivalentCreateRequest(existing, data, actor)) { + if (!isEquivalentCreateRequest(existing, normalizedData, actor)) { throw conflict("Interaction idempotency key already exists for a different request", { - idempotencyKey: data.idempotencyKey, + idempotencyKey: normalizedData.idempotencyKey, }); } return hydrateInteraction(existing); @@ -1459,6 +1613,7 @@ export function issueThreadInteractionService(db: Db) { ): Promise => { const data = acceptIssueThreadInteractionSchema.parse(input); const current = await getPendingInteractionForResolution({ issue, interactionId }); + assertAgentResolutionAllowed(current, actor); switch (current.kind) { case "suggest_tasks": // Accepting suggest_tasks only creates follow-up issues; it does not @@ -1514,6 +1669,7 @@ export function issueThreadInteractionService(db: Db) { if (current.companyId !== issue.companyId || current.issueId !== issue.id) { throw notFound("Interaction not found"); } + assertAgentResolutionAllowed(current, actor); if (current.kind !== "suggest_tasks") { throw unprocessable("Only suggest_tasks interactions can be accepted"); } @@ -1560,6 +1716,7 @@ export function issueThreadInteractionService(db: Db) { .set({ status: "accepted", resolvedByAgentId: actor.agentId ?? null, + resolvedByRunId: actor.runId ?? null, resolvedByUserId: actor.userId ?? null, resolvedAt, updatedAt: resolvedAt, @@ -1657,6 +1814,7 @@ export function issueThreadInteractionService(db: Db) { ) => { const data = rejectIssueThreadInteractionSchema.parse(input); const current = await getPendingInteractionForResolution({ issue, interactionId }); + assertAgentResolutionAllowed(current, actor); switch (current.kind) { case "suggest_tasks": return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current); @@ -1745,6 +1903,7 @@ export function issueThreadInteractionService(db: Db) { status: complete ? "answered" : "pending", result, resolvedByAgentId: complete ? actor.agentId ?? null : null, + resolvedByRunId: complete ? actor.runId ?? null : null, resolvedByUserId: complete ? actor.userId ?? null : null, resolvedAt: complete ? now : null, updatedAt: now, @@ -1799,6 +1958,7 @@ export function issueThreadInteractionService(db: Db) { rejectionReason: input.reason?.trim() || null, }, resolvedByAgentId: actor.agentId ?? null, + resolvedByRunId: actor.runId ?? null, resolvedByUserId: actor.userId ?? null, resolvedAt: new Date(), updatedAt: new Date(), @@ -2210,6 +2370,7 @@ export function issueThreadInteractionService(db: Db) { status: "cancelled", result: buildAdministrativeOutcomeResult(current, "withdrawn", reason), resolvedByAgentId: actor.agentId ?? null, + resolvedByRunId: actor.runId ?? null, resolvedByUserId: actor.userId ?? null, resolvedAt: now, updatedAt: now, @@ -2241,10 +2402,11 @@ export function issueThreadInteractionService(db: Db) { .where(eq(issueThreadInteractions.id, interactionId)) .then((rows) => rows[0] ?? null); - if (!current) throw notFound("Interaction not found"); - if (current.companyId !== issue.companyId || current.issueId !== issue.id) { - throw notFound("Interaction not found"); - } + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + assertAgentResolutionAllowed(current, actor); if (current.kind !== "ask_user_questions") { throw unprocessable("Only ask_user_questions interactions can be answered"); } @@ -2267,7 +2429,8 @@ export function issueThreadInteractionService(db: Db) { answers: normalizedAnswers, summaryMarkdown: input.summaryMarkdown ?? null, }, - resolvedByAgentId: actor.agentId ?? null, + resolvedByAgentId: actor.agentId ?? null, + resolvedByRunId: actor.runId ?? null, resolvedByUserId: actor.userId ?? null, resolvedAt: new Date(), updatedAt: new Date(), @@ -2325,6 +2488,7 @@ export function issueThreadInteractionService(db: Db) { summaryMarkdown: null, }, resolvedByAgentId: actor.agentId ?? null, + resolvedByRunId: actor.runId ?? null, resolvedByUserId: actor.userId ?? null, resolvedAt: new Date(), updatedAt: new Date(), diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 6582aa6cea..4091f04e06 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -905,6 +905,12 @@ POST /api/issues/{issueId}/interactions } ``` +Resolver governance: + +- Create accepts optional `resolverPolicy: "board_only" | "board_or_agents"`. If omitted, the company per-kind default applies (`ask_user_questions` defaults to `board_or_agents`; every other kind defaults to `board_only`). The response snapshots immutable `requestedResolverPolicy` and `effectiveResolverPolicy`; later governance edits never widen an existing pending card. `PATCH /api/companies/{companyId}` accepts `interactionResolverGovernance` keyed by kind, with optional `defaultPolicy` and `cap`; a `board_only` cap always wins. +- Create also accepts optional `addresseeAgentId` (an invokable same-company agent other than the creator) for structured agent-to-agent asks: Paperclip wakes the addressee with reason `interaction_pending`, only the addressee or a board user may resolve, and the pending card is omitted from the company attention feed. Not allowed with `request_confirmation.payload.toolAction` (`400`). +- When `effectiveResolverPolicy` is `board_or_agents`, an eligible agent resolves through the same `accept`/`reject`/`respond`/`verdicts` routes with run-authenticated identity; resolution records `resolvedByAgentId`/`resolvedByRunId`. The resolver cannot be the creator agent or source run, low-trust and watchdog-scoped actors are denied, and `payload.toolAction` confirmations stay board-only regardless of policy. + Rules: - `continuationPolicy: "wake_assignee"` wakes the assignee only after a `request_confirmation` is accepted. diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index 45f5e77b27..2b43268e2d 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -84,6 +84,7 @@ export const companiesApi = { | "budgetMonthlyCents" | "attachmentMaxBytes" | "requireBoardApprovalForNewAgents" + | "interactionResolverGovernance" | "feedbackDataSharingEnabled" | "brandColor" | "logoAssetId" diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index b282112cfc..3494a93861 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -211,6 +211,9 @@ function createSuggestedTasksInteraction( }, result: null, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } @@ -250,6 +253,9 @@ function createQuestionInteraction( }, result: null, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } @@ -283,6 +289,9 @@ function createExpiredRequestConfirmationInteraction( commentId: "comment-1", }, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index e2ebc7b882..2091aeacfd 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -28,6 +28,10 @@ import { supersededRequestItemVerdictsInteraction, staleTargetRequestConfirmationInteraction, rejectedSuggestedTasksInteraction, + agentAddressedRequestConfirmationInteraction, + agentResolvedRequestConfirmationInteraction, + withdrawnRequestConfirmationInteraction, + issueClosedRequestConfirmationInteraction, } from "../fixtures/issueThreadInteractionFixtures"; let root: Root | null = null; @@ -261,8 +265,8 @@ describe("IssueThreadInteractionCard", () => { }, }); - expect(host.textContent).toContain("Expired when issue closed"); - expect(host.textContent).toContain("The issue was closed before this confirmation was resolved."); + expect(host.textContent).toContain("Expired · issue closed"); + expect(host.textContent).toContain("This confirmation expired automatically when the issue reached a terminal state."); expect(host.textContent).not.toContain("Expired by target change"); }); @@ -301,8 +305,8 @@ describe("IssueThreadInteractionCard", () => { }, }); - expect(host.textContent).toContain("Questions expired when issue closed"); - expect(host.textContent).toContain("The issue was closed before these questions were answered."); + expect(host.textContent).toContain("Questions expired when the issue closed"); + expect(host.textContent).toContain("This question request expired automatically when the issue reached a terminal state."); expect(host.textContent).not.toContain("expired by comment"); }); @@ -785,4 +789,82 @@ describe("IssueThreadInteractionCard tool-action card", () => { expect(host.textContent).not.toContain("Approve & run"); expect(host.textContent).not.toContain("Technical details"); }); + + it("renders the agents-may-resolve policy badge and addressee chip", () => { + const host = renderCard({ + interaction: agentAddressedRequestConfirmationInteraction, + }); + + const policyBadge = host.querySelector('[data-testid="interaction-policy-badge"]'); + expect(policyBadge?.textContent).toContain("Agents may resolve"); + + const addresseeBadge = host.querySelector('[data-testid="interaction-addressee-badge"]'); + expect(addresseeBadge?.textContent).toContain("For "); + }); + + it("omits the policy and addressee badges for a board-only interaction", () => { + const host = renderCard({ + interaction: pendingRequestConfirmationInteraction, + }); + + expect(host.querySelector('[data-testid="interaction-policy-badge"]')).toBeNull(); + expect(host.querySelector('[data-testid="interaction-addressee-badge"]')).toBeNull(); + }); + + it("marks agent resolution with an audit chip in the resolved footer", () => { + const host = renderCard({ + interaction: agentResolvedRequestConfirmationInteraction, + }); + + const footer = host.querySelector('[data-testid="interaction-resolved-footer"]'); + expect(footer?.textContent).toContain("Resolved by"); + expect( + host.querySelector('[data-testid="interaction-resolved-by-agent-chip"]'), + ).not.toBeNull(); + }); + + it("renders a withdrawn footer with the withdrawer, reason, and agent chip", () => { + const host = renderCard({ + interaction: withdrawnRequestConfirmationInteraction, + }); + + // Header status reads "Withdrawn", not the raw "Cancelled" status. + expect(host.textContent).toContain("Withdrawn"); + // Withdrawn is a neutral administrative retraction — it must NOT wear the + // cancelled/rejected costume (rose/red border + XCircle). The shell is muted + // (border-border), never a rose/red alarm colour (design review R2). + const cardRoot = host.querySelector("div.rounded-lg.p-5.shadow-none"); + expect(cardRoot?.className).toContain("border-border"); + expect(cardRoot?.className).not.toMatch(/border-(rose|red)/); + // The header status icon is MinusCircle ("retracted"), never XCircle ("denied"). + const statusIcon = cardRoot?.querySelector("svg"); + expect(statusIcon?.getAttribute("class")).toContain("lucide-circle-minus"); + expect(statusIcon?.getAttribute("class")).not.toContain("lucide-circle-x"); + const footer = host.querySelector('[data-testid="interaction-withdrawn-footer"]'); + expect(footer?.textContent).toContain("Withdrawn by"); + expect(footer?.textContent).toContain("Plan superseded by a newer revision"); + expect( + footer?.querySelector('[data-testid="interaction-resolved-by-agent-chip"]'), + ).not.toBeNull(); + // The generic "Resolved by" footer must not double-render. + expect(host.querySelector('[data-testid="interaction-resolved-footer"]')).toBeNull(); + }); + + it("renders an issue-closed expiry footer for terminal auto-expiry", () => { + const host = renderCard({ + interaction: issueClosedRequestConfirmationInteraction, + }); + + // Footer is trimmed to just the audit timestamp — the header status badge + // already carries the "Expired · issue closed" label, so the footer must + // not restate it. + const footer = host.querySelector('[data-testid="interaction-issue-closed-footer"]'); + expect(footer?.textContent).toContain("Apr 20"); + expect(footer?.textContent).not.toContain("Expired when the issue closed"); + // The "Expired · issue closed" label survives exactly once (the header + // status badge); the duplicate body eyebrow was dropped. + const label = "Expired · issue closed"; + const occurrences = (host.textContent ?? "").split(label).length - 1; + expect(occurrences).toBe(1); + }); }); diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 59f3a8e111..633f2f650b 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Agent } from "@paperclipai/shared"; -import { AlertTriangle, ArrowUpRight, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Wrench, X, XCircle } from "lucide-react"; +import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Users, Wrench, X, XCircle } from "lucide-react"; import { Link } from "@/lib/router"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { @@ -92,6 +92,32 @@ function resolveActorLabel(args: { return "Unknown"; } +/** + * Administrative terminal outcomes (P1): an interaction that was withdrawn by + * its board/agent, or auto-expired when its issue reached a terminal state. + * Both are stored as `status="cancelled"|"expired"` with the distinguishing + * fact carried on `result.outcome` (there is no dedicated `withdrawn` status). + */ +function getAdministrativeOutcome( + interaction: IssueThreadInteraction, +): "withdrawn" | "issue_closed" | null { + const result = interaction.result; + if (result && typeof result === "object" && "outcome" in result) { + const outcome = (result as { outcome?: string | null }).outcome; + if (outcome === "withdrawn" || outcome === "issue_closed") return outcome; + } + return null; +} + +function getAdministrativeReason(interaction: IssueThreadInteraction): string | null { + const result = interaction.result; + if (result && typeof result === "object" && "reason" in result) { + const reason = (result as { reason?: string | null }).reason; + if (typeof reason === "string" && reason.trim().length > 0) return reason.trim(); + } + return null; +} + function statusLabel(status: IssueThreadInteraction["status"]) { switch (status) { case "pending": @@ -1175,12 +1201,16 @@ function AskUserQuestionsCard({
{interaction.result?.outcome === "issue_closed" - ? questions.length === 1 ? "Question expired when issue closed" : "Questions expired when issue closed" - : questions.length === 1 ? "Question expired by comment" : "Questions expired by comment"} + ? questions.length === 1 + ? "Question expired when the issue closed" + : "Questions expired when the issue closed" + : questions.length === 1 + ? "Question expired by comment" + : "Questions expired by comment"}

{interaction.result?.outcome === "issue_closed" - ? "The issue was closed before these questions were answered." + ? "This question request expired automatically when the issue reached a terminal state." : "A later board/user comment superseded this question request. Create a fresh request if answers are still needed."}

{interaction.result?.commentId ? ( @@ -1360,35 +1390,40 @@ function RequestConfirmationResolution({ } if (interaction.status === "cancelled" && outcome === "withdrawn") { + // Withdrawn is a neutral administrative retraction (P4 design review): the + // card-level withdrawn footer carries the "Withdrawn by …" attribution and + // reason, so this body only anchors the target chip — no rose/red styling + // and no duplicated reason text. return ( -
-
- Withdrawn - -
- {interaction.result?.reason ? ( -
- {interaction.result.reason} -
- ) : null} +
+ Withdrawn +
); } if (interaction.status === "expired") { const expiredByComment = outcome === "superseded_by_comment"; - const expiredWithIssue = outcome === "issue_closed"; + const expiredByIssueClosed = outcome === "issue_closed"; const expiredByTargetChange = outcome === "stale_target"; return (
-
- {expiredByComment ? "Expired by comment" : expiredWithIssue ? "Expired when issue closed" : "Expired by target change"} -
+ {/* + * issue_closed already carries its label in the header status badge + * ("Expired · issue closed"), so this eyebrow would duplicate it + * verbatim — only render the eyebrow for the states the header shows + * generically as "Expired". + */} + {expiredByIssueClosed ? null : ( +
+ {expiredByComment ? "Expired by comment" : "Expired by target change"} +
+ )}

{expiredByComment ? "A board comment superseded this confirmation before it was resolved." - : expiredWithIssue - ? "The issue was closed before this confirmation was resolved." + : expiredByIssueClosed + ? "This confirmation expired automatically when the issue reached a terminal state." : "The requested target changed before this confirmation was resolved."}

{expiredByComment && interaction.result?.commentId ? ( @@ -3076,9 +3111,26 @@ export function IssueThreadInteractionCard({ ) : null; const activeStyles = toolActionStyles ?? planStyles; - const StatusIcon = activeStyles ? activeStyles.Icon : statusIcon(interaction.status); + const adminOutcome = getAdministrativeOutcome(interaction); + const adminReason = adminOutcome ? getAdministrativeReason(interaction) : null; + // P4 (design review R2): a withdrawal is a neutral administrative retraction by + // the requester — NOT a board "no". It must not inherit the `cancelled` card's + // rose/red border + XCircle, which is pixel-identical to a rejected plan and + // mis-signals a denial to anyone scanning the thread. Give withdrawn its own + // inert lane (sibling to the calm `expired` state): muted border/badge + + // MinusCircle ("retracted"). This overrides the plan/tool-action/status styling + // so a withdrawn plan or confirmation reads "closed", not "changes requested". + const withdrawnStyles = + adminOutcome === "withdrawn" + ? { shell: "border-border bg-transparent", badge: "border-border bg-muted/60 text-muted-foreground" } + : null; + const StatusIcon = withdrawnStyles + ? MinusCircle + : activeStyles + ? activeStyles.Icon + : statusIcon(interaction.status); const iconSpin = toolActionStyles?.spin ?? false; - const styles = activeStyles ?? statusClasses(interaction.status); + const styles = withdrawnStyles ?? activeStyles ?? statusClasses(interaction.status); const createdByLabel = resolveActorLabel({ agentId: interaction.createdByAgentId, userId: interaction.createdByUserId, @@ -3096,6 +3148,27 @@ export function IssueThreadInteractionCard({ userLabelMap, }) : null; + // P4: audit-visible distinction between agent and human resolution. + const resolvedByAgent = Boolean(interaction.resolvedByAgentId); + // P2: agents may resolve when the governance-capped policy allows it. + const agentsMayResolve = interaction.effectiveResolverPolicy === "board_or_agents"; + // P3: interactions directed at a specific agent addressee. + const addresseeLabel = interaction.addresseeAgentId + ? resolveActorLabel({ + agentId: interaction.addresseeAgentId, + agentMap, + currentUserId, + userLabelMap, + }) + : null; + const statusText = + adminOutcome === "withdrawn" + ? "Withdrawn" + : adminOutcome === "issue_closed" + ? "Expired · issue closed" + : activeStyles + ? activeStyles.label + : statusLabel(interaction.status); return (
@@ -3106,8 +3179,42 @@ export function IssueThreadInteractionCard({ {isPlan ? "Plan" : interactionKindLabel(interaction.kind)} / - {activeStyles ? activeStyles.label : statusLabel(interaction.status)} + {statusText} + {agentsMayResolve ? ( + + + + + Agents may resolve + + + + Governance allows an assigned agent to resolve this interaction without waiting for the board. + + + ) : null} + {addresseeLabel ? ( + + + + + For {addresseeLabel} + + + + Directed to {addresseeLabel}. Agent-addressed interactions are handled by that agent and are kept out of the board attention feed. + + + ) : null}
@@ -3199,12 +3306,64 @@ export function IssueThreadInteractionCard({ )}
- {resolvedByLabel && !isToolAction ? ( -
+ {adminOutcome === "withdrawn" ? ( +
+
+ Withdrawn by{" "} + {resolvedByLabel ?? "an agent"} + {resolvedByAgent ? : null} + {interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""} +
+ {adminReason ? ( +
"{adminReason}"
+ ) : null} +
+ ) : adminOutcome === "issue_closed" && interaction.resolvedAt ? ( + // The header badge + body already explain the issue-closed expiry; + // the footer is just the audit timestamp. +
+ {formatShortDate(interaction.resolvedAt)} +
+ ) : resolvedByLabel && !isToolAction ? ( +
Resolved by {resolvedByLabel} + {resolvedByAgent ? : null} {interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""}
) : null}
); } + +/** + * Small audit chip marking that an interaction was resolved by an agent (rather + * than a human board member) — governed agent resolution introduced in P2. + */ +function ResolvedByAgentChip() { + return ( + + + + + Agent + + + + Resolved by an agent under the company's interaction governance policy — audit-distinct from a human board resolution. + + + ); +} diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index 60d7ce6516..3a8788c31b 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -22,6 +22,9 @@ function createRequestConfirmation( summary: "Review and approve the latest plan.", status: "pending", continuationPolicy: "wake_assignee", + resolverPolicy: "board_only", + requestedResolverPolicy: "board_only", + effectiveResolverPolicy: "board_only", createdByAgentId: "agent-1", createdByUserId: null, resolvedByAgentId: null, diff --git a/ui/src/context/CompanyContext.test.tsx b/ui/src/context/CompanyContext.test.tsx index 43beef5b53..cb4ffd4a20 100644 --- a/ui/src/context/CompanyContext.test.tsx +++ b/ui/src/context/CompanyContext.test.tsx @@ -41,6 +41,7 @@ function makeCompany(id: string): Company { attachmentMaxBytes: 10 * 1024 * 1024, defaultResponsibleUserId: null, requireBoardApprovalForNewAgents: false, + interactionResolverGovernance: {}, feedbackDataSharingEnabled: false, feedbackDataSharingConsentAt: null, feedbackDataSharingConsentByUserId: null, diff --git a/ui/src/fixtures/issueThreadInteractionFixtures.ts b/ui/src/fixtures/issueThreadInteractionFixtures.ts index 3faa9569ab..7374709b72 100644 --- a/ui/src/fixtures/issueThreadInteractionFixtures.ts +++ b/ui/src/fixtures/issueThreadInteractionFixtures.ts @@ -105,6 +105,9 @@ function createSuggestTasksInteraction( }, result: null, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } @@ -181,6 +184,9 @@ function createAskUserQuestionsInteraction( }, result: null, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } @@ -224,6 +230,9 @@ function createRequestConfirmationInteraction( }, result: null, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } @@ -283,6 +292,9 @@ function createRequestCheckboxConfirmationInteraction( }, result: null, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } @@ -735,6 +747,74 @@ export const failedRequestConfirmationInteraction = createRequestConfirmationInt updatedAt: new Date("2026-04-20T14:42:00.000Z"), }); +// --- P4 governance / lifecycle card states (PAP-15427) --- + +// Agent-addressed, agents-may-resolve pending confirmation: exercises the +// header policy badge + addressee chip. +export const agentAddressedRequestConfirmationInteraction = + createRequestConfirmationInteraction({ + id: "interaction-confirmation-agent-addressed", + title: "Confirm the deploy window with the release agent", + summary: + "Directed to the release agent, who is permitted to resolve this without waiting on the board.", + addresseeAgentId: "agent-codex", + requestedResolverPolicy: "board_or_agents", + resolverPolicy: "board_or_agents", + effectiveResolverPolicy: "board_or_agents", + }); + +// Confirmation resolved by an agent under governance: exercises the +// "Resolved by … / Agent" audit chip. +export const agentResolvedRequestConfirmationInteraction = + createRequestConfirmationInteraction({ + id: "interaction-confirmation-agent-resolved", + title: "Approved by the release agent", + status: "accepted", + createdByAgentId: "agent-codex", + resolvedByAgentId: "agent-codex", + resolvedByRunId: "run-agent-resolve-1", + requestedResolverPolicy: "board_or_agents", + resolverPolicy: "board_or_agents", + effectiveResolverPolicy: "board_or_agents", + resolvedAt: new Date("2026-04-20T15:05:00.000Z"), + updatedAt: new Date("2026-04-20T15:05:00.000Z"), + result: { version: 1, outcome: "accepted" }, + }); + +// Withdrawn confirmation (status=cancelled + result.outcome=withdrawn): exercises +// the "Withdrawn by … / reason" footer. +export const withdrawnRequestConfirmationInteraction = + createRequestConfirmationInteraction({ + id: "interaction-confirmation-withdrawn", + title: "Withdrawn: approve the plan", + status: "cancelled", + createdByAgentId: "agent-codex", + resolvedByAgentId: "agent-codex", + resolvedByRunId: "run-agent-withdraw-1", + resolvedAt: new Date("2026-04-20T15:10:00.000Z"), + updatedAt: new Date("2026-04-20T15:10:00.000Z"), + result: { + version: 1, + outcome: "withdrawn", + reason: "Plan superseded by a newer revision; no board decision needed.", + }, + }); + +// Interaction auto-expired when its issue reached a terminal state. +export const issueClosedRequestConfirmationInteraction = + createRequestConfirmationInteraction({ + id: "interaction-confirmation-issue-closed", + title: "Expired: confirm the migration cutover", + status: "expired", + resolvedAt: new Date("2026-04-20T15:12:00.000Z"), + updatedAt: new Date("2026-04-20T15:12:00.000Z"), + result: { + version: 1, + outcome: "issue_closed", + reason: "Issue was closed before the confirmation was resolved.", + }, + }); + export const pendingRequestCheckboxConfirmationInteraction = createRequestCheckboxConfirmationInteraction({}); @@ -946,6 +1026,9 @@ function createRequestItemVerdictsInteraction( }, result: null, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 5b36fe8b37..365b985b7b 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -92,6 +92,9 @@ function createInteraction( }, result: null, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } @@ -120,6 +123,9 @@ function createRequestConfirmation( }, result: null, ...overrides, + resolverPolicy: overrides.resolverPolicy ?? "board_only", + requestedResolverPolicy: overrides.requestedResolverPolicy ?? "board_only", + effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "board_only", }; } diff --git a/ui/src/lib/issue-thread-interactions.test.ts b/ui/src/lib/issue-thread-interactions.test.ts index a573fdce07..ed4310d376 100644 --- a/ui/src/lib/issue-thread-interactions.test.ts +++ b/ui/src/lib/issue-thread-interactions.test.ts @@ -12,6 +12,12 @@ import { } from "./issue-thread-interactions"; import type { RequestItemVerdictsInteraction } from "./issue-thread-interactions"; +const resolverPolicyFields = { + resolverPolicy: "board_only", + requestedResolverPolicy: "board_only", + effectiveResolverPolicy: "board_only", +} as const; + describe("buildSuggestedTaskTree", () => { it("preserves parent-child relationships from client keys", () => { const roots = buildSuggestedTaskTree([ @@ -48,6 +54,7 @@ describe("issue thread interaction helpers", () => { kind: "suggest_tasks", status: "pending", continuationPolicy: "wake_assignee", + ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { @@ -66,6 +73,7 @@ describe("issue thread interaction helpers", () => { kind: "suggest_tasks", status: "accepted", continuationPolicy: "wake_assignee", + ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { @@ -89,6 +97,7 @@ describe("issue thread interaction helpers", () => { kind: "ask_user_questions", status: "pending", continuationPolicy: "wake_assignee", + ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { @@ -111,6 +120,7 @@ describe("issue thread interaction helpers", () => { kind: "ask_user_questions", status: "answered", continuationPolicy: "wake_assignee", + ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { @@ -137,6 +147,7 @@ describe("issue thread interaction helpers", () => { kind: "ask_user_questions", status: "expired", continuationPolicy: "wake_assignee", + ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:05:00.000Z", payload: { @@ -166,6 +177,7 @@ describe("issue thread interaction helpers", () => { issueId: "issue-1", kind: "request_checkbox_confirmation" as const, continuationPolicy: "wake_assignee" as const, + ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { @@ -298,6 +310,7 @@ describe("per-item verdict helpers", () => { kind: "request_item_verdicts", status: "pending", continuationPolicy: "wake_assignee", + ...resolverPolicyFields, createdAt: "2026-04-06T12:00:00.000Z", updatedAt: "2026-04-06T12:00:00.000Z", payload: { diff --git a/ui/src/lib/issue-thread-interactions.ts b/ui/src/lib/issue-thread-interactions.ts index 9eb883269c..1e20e13e27 100644 --- a/ui/src/lib/issue-thread-interactions.ts +++ b/ui/src/lib/issue-thread-interactions.ts @@ -183,6 +183,7 @@ export function buildIssueThreadInteractionSummary( : null; if (administrativeOutcome === "withdrawn") return "Withdrawn interaction"; if (administrativeOutcome === "issue_closed") return "Expired when issue closed"; + if (administrativeOutcome === "addressee_deleted") return "Cancelled when addressee was deleted"; if (interaction.kind === "suggest_tasks") { const count = interaction.payload.tasks.length; if (interaction.status === "accepted") { diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index b9d2e44328..e057e0ea30 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -1,8 +1,12 @@ -import { ChangeEvent, useEffect, useState } from "react"; +import { ChangeEvent, Fragment, useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES, MAX_COMPANY_ATTACHMENT_MAX_BYTES, + ISSUE_THREAD_INTERACTION_KINDS, + type InteractionResolverGovernance, + type IssueThreadInteractionKind, + type IssueThreadInteractionResolverPolicy, } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -11,6 +15,13 @@ import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { Link } from "@/lib/router"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Settings, Download, Upload } from "lucide-react"; import { CompanyPatternIcon } from "../components/CompanyPatternIcon"; import { @@ -21,6 +32,101 @@ import { const BYTES_PER_MIB = 1024 * 1024; const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB; const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB; + +const INTERACTION_KIND_LABELS: Record = { + suggest_tasks: "Suggested tasks", + ask_user_questions: "Ask user questions", + request_confirmation: "Confirmations", + request_checkbox_confirmation: "Checkbox confirmations", + request_item_verdicts: "Item verdicts", +}; + +// Sentinel for "no override" — Radix Select disallows empty-string item values. +const GOVERNANCE_UNSET = "default"; +type GovernanceSelectValue = typeof GOVERNANCE_UNSET | IssueThreadInteractionResolverPolicy; + +const GOVERNANCE_POLICY_OPTIONS: { value: GovernanceSelectValue; label: string }[] = [ + { value: GOVERNANCE_UNSET, label: "Company default" }, + { value: "board_only", label: "Board only" }, + { value: "board_or_agents", label: "Board or agents" }, +]; + +function toSelectValue(policy: IssueThreadInteractionResolverPolicy | undefined): GovernanceSelectValue { + return policy ?? GOVERNANCE_UNSET; +} + +/** + * Apply a single (kind, field) change to a governance map immutably, pruning + * empty entries so the persisted object stays sparse (only real overrides). + */ +function applyGovernanceChange( + current: InteractionResolverGovernance, + kind: IssueThreadInteractionKind, + field: "defaultPolicy" | "cap", + value: GovernanceSelectValue, +): InteractionResolverGovernance { + const next: InteractionResolverGovernance = { ...current }; + const entry = { ...(next[kind] ?? {}) }; + if (value === GOVERNANCE_UNSET) { + delete entry[field]; + } else { + entry[field] = value; + } + if (entry.defaultPolicy === undefined && entry.cap === undefined) { + delete next[kind]; + } else { + next[kind] = entry; + } + return next; +} +function GovernanceSelect({ + value, + onChange, + disabled, + testId, + ariaLabel, + mobileLabel, +}: { + value: GovernanceSelectValue; + onChange: (value: GovernanceSelectValue) => void; + disabled?: boolean; + testId?: string; + ariaLabel: string; + mobileLabel: string; +}) { + return ( +
+ {/* + * Below `sm` the governance grid collapses to a single column (see the + * grid classes on the panel), detaching each select from its column + * header. Surface a mobile-only inline label so the control stays + * self-describing for sighted users, and always carry `aria-label` for + * screen-reader pairing. WCAG 2.1 SC 1.4.10 (Reflow) — design review R2. + */} + + {mobileLabel} + + +
+ ); +} + export function CompanySettings() { const { companies, @@ -37,6 +143,7 @@ export function CompanySettings() { const [attachmentMaxMiB, setAttachmentMaxMiB] = useState(String(DEFAULT_COMPANY_ATTACHMENT_MAX_MIB)); const [logoUrl, setLogoUrl] = useState(""); const [logoUploadError, setLogoUploadError] = useState(null); + const [governance, setGovernance] = useState({}); // Sync local state from selected company useEffect(() => { @@ -46,6 +153,7 @@ export function CompanySettings() { setBrandColor(selectedCompany.brandColor ?? ""); setAttachmentMaxMiB(String(Math.round((selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES) / BYTES_PER_MIB))); setLogoUrl(selectedCompany.logoUrl ?? ""); + setGovernance(selectedCompany.interactionResolverGovernance ?? {}); }, [selectedCompany]); const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB; @@ -83,6 +191,25 @@ export function CompanySettings() { } }); + const governanceMutation = useMutation({ + mutationFn: (next: InteractionResolverGovernance) => + companiesApi.update(selectedCompanyId!, { interactionResolverGovernance: next }), + onSuccess: (company) => { + setGovernance(company.interactionResolverGovernance ?? {}); + queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + } + }); + + function handleGovernanceChange( + kind: IssueThreadInteractionKind, + field: "defaultPolicy" | "cap", + value: GovernanceSelectValue, + ) { + const next = applyGovernanceChange(governance, kind, field, value); + setGovernance(next); + governanceMutation.mutate(next); + } + const syncLogoState = (nextLogoUrl: string | null) => { setLogoUrl(nextLogoUrl ?? ""); void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); @@ -361,6 +488,75 @@ export function CompanySettings() {
+ {/* Interaction governance */} +
+
+ Interaction governance +
+
+

+ Control who may resolve each kind of thread interaction.{" "} + Default policy is the + resolver policy new interactions request;{" "} + Cap is the maximum a + request may reach — set it to{" "} + Board only to always + require the board. Tool-approval confirmations always stay board-only + regardless of these settings. +

+ {/* + * Responsive: below `sm` the row collapses to a single column so the + * two 170px selects never force horizontal overflow on a ~390px + * viewport (WCAG 2.1 SC 1.4.10 Reflow — design review R2). Each kind + * then stacks as: label → Default policy → Cap, each full-width with + * its own inline label. At `sm`+ it restores the aligned 3-col grid. + */} +
+
+ Kind +
+
+ Default policy +
+
+ Cap +
+ {ISSUE_THREAD_INTERACTION_KINDS.map((kind) => { + const entry = governance[kind] ?? {}; + const kindLabel = INTERACTION_KIND_LABELS[kind]; + return ( + +
{kindLabel}
+ handleGovernanceChange(kind, "defaultPolicy", v)} + /> + handleGovernanceChange(kind, "cap", v)} + /> +
+ ); + })} +
+ {governanceMutation.isError && ( + + {governanceMutation.error instanceof Error + ? governanceMutation.error.message + : "Failed to save interaction governance"} + + )} +
+
+ {/* Import / Export */}
diff --git a/ui/storybook/fixtures/paperclipData.ts b/ui/storybook/fixtures/paperclipData.ts index 3eff9bd5f9..dc619fa664 100644 --- a/ui/storybook/fixtures/paperclipData.ts +++ b/ui/storybook/fixtures/paperclipData.ts @@ -45,6 +45,7 @@ export const storybookCompanies: Company[] = [ attachmentMaxBytes: 10 * 1024 * 1024, defaultResponsibleUserId: "user-board", requireBoardApprovalForNewAgents: true, + interactionResolverGovernance: {}, feedbackDataSharingEnabled: true, feedbackDataSharingConsentAt: null, feedbackDataSharingConsentByUserId: null, @@ -69,6 +70,7 @@ export const storybookCompanies: Company[] = [ attachmentMaxBytes: 10 * 1024 * 1024, defaultResponsibleUserId: "user-board", requireBoardApprovalForNewAgents: false, + interactionResolverGovernance: {}, feedbackDataSharingEnabled: false, feedbackDataSharingConsentAt: null, feedbackDataSharingConsentByUserId: null, @@ -93,6 +95,7 @@ export const storybookCompanies: Company[] = [ attachmentMaxBytes: 10 * 1024 * 1024, defaultResponsibleUserId: "user-board", requireBoardApprovalForNewAgents: true, + interactionResolverGovernance: {}, feedbackDataSharingEnabled: false, feedbackDataSharingConsentAt: null, feedbackDataSharingConsentByUserId: null,