feat(interactions): add governed agent addressees (#10252)
## Thinking Path > - Paperclip is the control plane that lets humans govern companies of AI agents. > - Issue-thread interactions are the structured handoff point for confirmations, questions, suggested tasks, and other governed decisions. > - Those interactions previously assumed that only board users could resolve them, preventing one agent from explicitly addressing another agent for a response. > - Agent resolution needs company-level governance, auditable resolver identity, safe terminal-state handling, and attention routing so authorization is enforced server-side rather than inferred from UI behavior. > - This pull request adds governed agent resolution, withdrawal and terminal expiry semantics, explicit agent addressees, lifecycle reconciliation, and attention-feed filtering. > - The benefit is that agents can participate in structured decisions without weakening board control, company isolation, wake behavior, or audit invariants. ## Linked Issues or Issue Description ### Subsystem affected Issue-thread interactions across database, shared contracts, server authorization/services, adapter callbacks, agent skill guidance, API docs, and UI governance surfaces. ### Problem or motivation Structured interactions were board-only, had no explicit agent addressee, and lacked durable withdrawal/terminal-expiry semantics. That made peer-agent decisions impossible to authorize and audit safely. ### Proposed solution Persist requested/effective resolver policy and addressee identity, enforce company governance and eligible agent resolution, reconcile addressee lifecycle changes, expose withdrawal and terminal expiry, and route attention to the intended active agent with board fallback. ### Alternatives considered Implicitly authorizing the issue assignee or mentioned agents was rejected as ambiguous and difficult to audit. Using comments alone was rejected because it loses structured outcomes and continuation behavior. ### Roadmap alignment Supports the ROADMAP direction for lightweight leadership-agent communication that still resolves into governed decisions and work objects. ### Additional context Public GitHub issue/PR search found no duplicate implementation; open PR search for interaction resolver governance and agent addressees only returned this PR. ## What Changed - Add company-scoped interaction resolver governance contracts and persistence. - Add requested/effective resolver policy, resolver identity, withdrawal, and terminal-expiry behavior. - Add explicit `addresseeAgentId` validation, authorization, persistence, lifecycle reconciliation, API documentation, and skill guidance. - Route pending addressed interactions to the intended invokable agent and fall back to board attention when that agent becomes ineligible or is deleted. - Preserve sandbox callback identity fields required by governed resolution paths. - Add migrations `0193` and `0194` plus route, service, attention, adapter, CLI, and UI coverage. - Add governance state and company settings UI, including responsive mobile behavior and distinct withdrawn/expired audit presentation. ## Verification - `pnpm check:token-gates` — passed. - `pnpm -r typecheck` — passed, including migration numbering and safety checks. - `pnpm test:run` — feature/server and UI workspace suites passed; one unrelated CLI AWS doctor test observed injected static AWS credentials and warned instead of passing. - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts --project paperclipai` — 8 tests passed, confirming the failure was environment-sensitive. - `pnpm build` — passed. - Latest rebased head `e24cece6be9f1877bdbac7691bcb44fd583c0161` completed all GitHub CI jobs successfully. ## Risks - Migrations add interaction and company-governance fields; numbering is conflict-free on current `master`, additive statements are idempotent, and migration safety checks pass. - Agent authorization behavior expands beyond board-only resolution, but defaults remain board-only and coverage exercises company boundaries, resolver eligibility, lifecycle invalidation, wake behavior, withdrawal, expiry, and attention fallback. - Attention routing depends on current agent invokability; reconciliation and read-time filtering prevent stale addressees from retaining visibility or resolution authority. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex using `gpt-5.6-sol` with reasoning, terminal tool use, code execution, Git/GitHub integration, and Paperclip control-plane tools. Context-window metadata was not reported by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f0b06d2de9
commit
ded813ad6f
|
|
@ -27,6 +27,7 @@ function makeCompany(overrides: Partial<Company>): Company {
|
|||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
interactionResolverGovernance: overrides.interactionResolverGovernance ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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$/ },
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<InteractionResolverGovernance>()
|
||||
.notNull()
|
||||
.default({}),
|
||||
feedbackDataSharingEnabled: boolean("feedback_data_sharing_enabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
|
|
|||
|
|
@ -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<IssueThreadInteractionResolverPolicy>()
|
||||
.notNull()
|
||||
.default("board_only"),
|
||||
effectiveResolverPolicy: text("effective_resolver_policy")
|
||||
.$type<IssueThreadInteractionResolverPolicy>()
|
||||
.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<IssueThreadInteractionPayload>().notNull(),
|
||||
result: jsonb("result").$type<IssueThreadInteractionResult>(),
|
||||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<IssueThreadInteractionKind, InteractionResolverKindGovernance>
|
||||
>;
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export type { Company } from "./company.js";
|
||||
export type {
|
||||
Company,
|
||||
InteractionResolverGovernance,
|
||||
InteractionResolverKindGovernance,
|
||||
} from "./company.js";
|
||||
export type {
|
||||
GenerateSummarySlotRequest,
|
||||
GenerateSummarySlotResponse,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export {
|
|||
|
||||
export {
|
||||
createCompanySchema,
|
||||
interactionResolverGovernanceSchema,
|
||||
updateCompanySchema,
|
||||
updateCompanyBrandingSchema,
|
||||
type CreateCompany,
|
||||
|
|
|
|||
|
|
@ -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<typeof addIssueCommentSchema>;
|
|||
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ function registerModuleMocks() {
|
|||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expirePendingInteractionsForTerminalIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 () => []),
|
||||
|
|
|
|||
|
|
@ -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 () => []),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 () => []),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 () => []),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 () => []),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 () => []),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 () => []),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ function registerModuleMocks() {
|
|||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expirePendingInteractionsForTerminalIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => []),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof assertAgentIssueMutationAllowed>[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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<Db, "update">;
|
||||
|
||||
const DEFAULT_RESOLVER_POLICY_BY_KIND: Record<IssueThreadInteractionKind, IssueThreadInteractionResolverPolicy> = {
|
||||
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<ResolvedInteractionResult> => {
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export const companiesApi = {
|
|||
| "budgetMonthlyCents"
|
||||
| "attachmentMaxBytes"
|
||||
| "requireBoardApprovalForNewAgents"
|
||||
| "interactionResolverGovernance"
|
||||
| "feedbackDataSharingEnabled"
|
||||
| "brandColor"
|
||||
| "logoAssetId"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<div className="flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{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"}
|
||||
</div>
|
||||
<p className="mt-1">
|
||||
{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."}
|
||||
</p>
|
||||
{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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
|
||||
<span className="font-medium">Withdrawn</span>
|
||||
<RequestConfirmationTargetChip interaction={interaction} target={target} />
|
||||
</div>
|
||||
{interaction.result?.reason ? (
|
||||
<div className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
|
||||
<MarkdownBody>{interaction.result.reason}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
|
||||
<span className="font-medium">Withdrawn</span>
|
||||
<RequestConfirmationTargetChip interaction={interaction} target={target} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
|
||||
<div className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-amber-700">
|
||||
{expiredByComment ? "Expired by comment" : expiredWithIssue ? "Expired when issue closed" : "Expired by target change"}
|
||||
</div>
|
||||
{/*
|
||||
* 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 : (
|
||||
<div className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-amber-700">
|
||||
{expiredByComment ? "Expired by comment" : "Expired by target change"}
|
||||
</div>
|
||||
)}
|
||||
<p className="leading-6">
|
||||
{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."}
|
||||
</p>
|
||||
{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 (
|
||||
<div className={cn("rounded-lg border p-5 shadow-none", styles.shell)}>
|
||||
|
|
@ -3106,8 +3179,42 @@ export function IssueThreadInteractionCard({
|
|||
<StatusIcon className={cn("h-3.5 w-3.5", iconSpin && "animate-spin")} />
|
||||
{isPlan ? "Plan" : interactionKindLabel(interaction.kind)}
|
||||
<span className="text-current/60">/</span>
|
||||
{activeStyles ? activeStyles.label : statusLabel(interaction.status)}
|
||||
{statusText}
|
||||
</span>
|
||||
{agentsMayResolve ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-indigo-500/50 text-indigo-700 dark:text-indigo-200"
|
||||
data-testid="interaction-policy-badge"
|
||||
>
|
||||
<Users className="h-3 w-3" />
|
||||
Agents may resolve
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs text-xs">
|
||||
Governance allows an assigned agent to resolve this interaction without waiting for the board.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{addresseeLabel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1"
|
||||
data-testid="interaction-addressee-badge"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
For {addresseeLabel}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs text-xs">
|
||||
Directed to {addresseeLabel}. Agent-addressed interactions are handled by that agent and are kept out of the board attention feed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-lg font-bold text-foreground">
|
||||
|
|
@ -3199,12 +3306,64 @@ export function IssueThreadInteractionCard({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{resolvedByLabel && !isToolAction ? (
|
||||
<div className="mt-4 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
{adminOutcome === "withdrawn" ? (
|
||||
<div
|
||||
className="mt-4 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-withdrawn-footer"
|
||||
>
|
||||
<div>
|
||||
Withdrawn by{" "}
|
||||
<span className="font-medium text-foreground">{resolvedByLabel ?? "an agent"}</span>
|
||||
{resolvedByAgent ? <ResolvedByAgentChip /> : null}
|
||||
{interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""}
|
||||
</div>
|
||||
{adminReason ? (
|
||||
<div className="mt-1 italic text-muted-foreground/90">"{adminReason}"</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : adminOutcome === "issue_closed" && interaction.resolvedAt ? (
|
||||
// The header badge + body already explain the issue-closed expiry;
|
||||
// the footer is just the audit timestamp.
|
||||
<div
|
||||
className="mt-4 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-issue-closed-footer"
|
||||
>
|
||||
{formatShortDate(interaction.resolvedAt)}
|
||||
</div>
|
||||
) : resolvedByLabel && !isToolAction ? (
|
||||
<div
|
||||
className="mt-4 flex flex-wrap items-center gap-x-1 gap-y-0.5 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-resolved-footer"
|
||||
>
|
||||
Resolved by <span className="font-medium text-foreground">{resolvedByLabel}</span>
|
||||
{resolvedByAgent ? <ResolvedByAgentChip /> : null}
|
||||
{interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 gap-1 border-indigo-500/50 py-0 text-[length:--text-micro] text-indigo-700 dark:text-indigo-200"
|
||||
data-testid="interaction-resolved-by-agent-chip"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
Agent
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs text-xs">
|
||||
Resolved by an agent under the company's interaction governance policy — audit-distinct from a human board resolution.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ function makeCompany(id: string): Company {
|
|||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
defaultResponsibleUserId: null,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
interactionResolverGovernance: {},
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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<IssueThreadInteractionKind, string> = {
|
||||
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 (
|
||||
<div className="min-w-0">
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<span className="mb-1 block text-xs font-medium text-muted-foreground uppercase tracking-wide sm:hidden">
|
||||
{mobileLabel}
|
||||
</span>
|
||||
<Select value={value} onValueChange={(v) => onChange(v as GovernanceSelectValue)} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
aria-label={ariaLabel}
|
||||
className="w-full min-w-0 text-xs sm:w-(--sz-170px)"
|
||||
data-testid={testId}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GOVERNANCE_POLICY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [governance, setGovernance] = useState<InteractionResolverGovernance>({});
|
||||
|
||||
// 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() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interaction governance */}
|
||||
<div className="space-y-4" data-testid="company-settings-interaction-governance-section">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Interaction governance
|
||||
</div>
|
||||
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Control who may resolve each kind of thread interaction.{" "}
|
||||
<span className="font-medium text-foreground">Default policy</span> is the
|
||||
resolver policy new interactions request;{" "}
|
||||
<span className="font-medium text-foreground">Cap</span> is the maximum a
|
||||
request may reach — set it to{" "}
|
||||
<span className="font-medium text-foreground">Board only</span> to always
|
||||
require the board. Tool-approval confirmations always stay board-only
|
||||
regardless of these settings.
|
||||
</p>
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-[1fr_auto_auto] sm:items-center sm:gap-x-4 sm:gap-y-2.5">
|
||||
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
||||
Kind
|
||||
</div>
|
||||
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
||||
Default policy
|
||||
</div>
|
||||
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
|
||||
Cap
|
||||
</div>
|
||||
{ISSUE_THREAD_INTERACTION_KINDS.map((kind) => {
|
||||
const entry = governance[kind] ?? {};
|
||||
const kindLabel = INTERACTION_KIND_LABELS[kind];
|
||||
return (
|
||||
<Fragment key={kind}>
|
||||
<div className="text-sm font-medium sm:font-normal">{kindLabel}</div>
|
||||
<GovernanceSelect
|
||||
testId={`governance-${kind}-default`}
|
||||
ariaLabel={`Default resolver policy for ${kindLabel}`}
|
||||
mobileLabel="Default policy"
|
||||
value={toSelectValue(entry.defaultPolicy)}
|
||||
disabled={governanceMutation.isPending}
|
||||
onChange={(v) => handleGovernanceChange(kind, "defaultPolicy", v)}
|
||||
/>
|
||||
<GovernanceSelect
|
||||
testId={`governance-${kind}-cap`}
|
||||
ariaLabel={`Resolver cap for ${kindLabel}`}
|
||||
mobileLabel="Cap"
|
||||
value={toSelectValue(entry.cap)}
|
||||
disabled={governanceMutation.isPending}
|
||||
onChange={(v) => handleGovernanceChange(kind, "cap", v)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{governanceMutation.isError && (
|
||||
<span className="text-xs text-destructive">
|
||||
{governanceMutation.error instanceof Error
|
||||
? governanceMutation.error.message
|
||||
: "Failed to save interaction governance"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import / Export */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue