[codex] Add structured issue-thread interactions (#4244)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Operators supervise that work through issues, comments, approvals,
and the board UI.
> - Some agent proposals need structured board/user decisions, not
hidden markdown conventions or heavyweight governed approvals.
> - Issue-thread interactions already provide a natural thread-native
surface for proposed tasks and questions.
> - This pull request extends that surface with request confirmations,
richer interaction cards, and agent/plugin/MCP helpers.
> - The benefit is that plan approvals and yes/no decisions become
explicit, auditable, and resumable without losing the single-issue
workflow.

## What Changed

- Added persisted issue-thread interactions for suggested tasks,
structured questions, and request confirmations.
- Added board UI cards for interaction review, selection, question
answers, and accept/reject confirmation flows.
- Added MCP and plugin SDK helpers for creating interaction cards from
agents/plugins.
- Updated agent wake instructions, onboarding assets, Paperclip skill
docs, and public docs to prefer structured confirmations for
issue-scoped decisions.
- Rebased the branch onto `public-gh/master` and renumbered branch
migrations to `0063` and `0064`; the idempotency migration uses `ADD
COLUMN IF NOT EXISTS` for old branch users.

## Verification

- `git diff --check public-gh/master..HEAD`
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
packages/mcp-server/src/tools.test.ts
packages/shared/src/issue-thread-interactions.test.ts
ui/src/lib/issue-thread-interactions.test.ts
ui/src/lib/issue-chat-messages.test.ts
ui/src/components/IssueThreadInteractionCard.test.tsx
ui/src/components/IssueChatThread.test.tsx
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/services/issue-thread-interactions.test.ts` -> 9 files / 79
tests passed
- `pnpm -r typecheck` -> passed, including `packages/db` migration
numbering check

## Risks

- Medium: this adds a new issue-thread interaction model across
db/shared/server/ui/plugin surfaces.
- Migration risk is reduced by placing this branch after current master
migrations (`0063`, `0064`) and making the idempotency column add
idempotent for users who applied the old branch numbering.
- UI interaction behavior is covered by component tests, but this PR
does not include browser screenshots.

> 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, GPT-5-class coding agent runtime. Exact model ID and
context window are not exposed in this Paperclip run; tool use and local
shell/code execution were enabled.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-21 20:15:11 -05:00 committed by GitHub
parent 014aa0eb2d
commit a957394420
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 10089 additions and 752 deletions

View file

@ -256,6 +256,11 @@ describe("renderPaperclipWakePrompt", () => {
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Create child issues directly when you know what needs to be done");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("POST /api/issues/{issueId}/interactions");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("kind suggest_tasks, ask_user_questions, or request_confirmation");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("confirmation:{issueId}:plan:{revisionId}");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Wait for acceptance before creating implementation subtasks");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
"Respect budget, pause/cancel, approval gates, and company boundaries",
);

View file

@ -84,6 +84,9 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
"- Leave durable progress in comments, documents, or work products with a clear next action.",
"- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.",
"- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.",
"- Create child issues directly when you know what needs to be done; use issue-thread interactions when the board/user must choose suggested tasks, answer structured questions, or confirm a proposal.",
"- To ask for that input, create an interaction on the current issue with POST /api/issues/{issueId}/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response; for request_confirmation this resumes only after acceptance.",
"- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.",
"- If blocked, mark the issue blocked and name the unblock owner and action.",
"- Respect budget, pause/cancel, approval gates, and company boundaries.",
].join("\n");

View file

@ -422,6 +422,8 @@ function buildWakeText(
" - GET /api/issues/{issueId}/comments",
" - Execute the issue instructions exactly. If the issue is actionable, take concrete action in this run; do not stop at a plan unless planning was requested.",
" - Leave durable progress with a clear next action. Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.",
" - Create child issues directly when you know what needs to be done; use POST /api/issues/{issueId}/interactions with kind suggest_tasks, ask_user_questions, or request_confirmation when the board/user must choose, answer, or confirm before you can continue.",
" - For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}; wait for acceptance before creating implementation subtasks.",
" - If blocked, PATCH /api/issues/{issueId} with {\"status\":\"blocked\",\"comment\":\"what is blocked, who owns the unblock, and the next action\"}.",
" - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.",
" - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.",

View file

@ -0,0 +1,65 @@
CREATE TABLE IF NOT EXISTS "issue_thread_interactions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"kind" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"continuation_policy" text DEFAULT 'wake_assignee' NOT NULL,
"source_comment_id" uuid,
"source_run_id" uuid,
"title" text,
"summary" text,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"resolved_by_agent_id" uuid,
"resolved_by_user_id" text,
"payload" jsonb NOT NULL,
"result" jsonb,
"resolved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_company_id_companies_id_fk') THEN
ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_issue_id_issues_id_fk') THEN
ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_source_comment_id_issue_comments_id_fk') THEN
ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_source_comment_id_issue_comments_id_fk" FOREIGN KEY ("source_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_source_run_id_heartbeat_runs_id_fk') THEN
ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_created_by_agent_id_agents_id_fk') THEN
ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_resolved_by_agent_id_agents_id_fk') THEN
ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_thread_interactions_issue_idx" ON "issue_thread_interactions" USING btree ("issue_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_created_at_idx" ON "issue_thread_interactions" USING btree ("company_id","issue_id","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_status_idx" ON "issue_thread_interactions" USING btree ("company_id","issue_id","status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_thread_interactions_source_comment_idx" ON "issue_thread_interactions" USING btree ("source_comment_id");

View file

@ -0,0 +1,4 @@
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "idempotency_key" text;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_idempotency_uq"
ON "issue_thread_interactions" USING btree ("company_id","issue_id","idempotency_key")
WHERE "issue_thread_interactions"."idempotency_key" IS NOT NULL;

View file

@ -442,6 +442,20 @@
"when": 1776780000000,
"tag": "0062_routine_run_dispatch_fingerprint",
"breakpoints": true
},
{
"idx": 63,
"version": "7",
"when": 1776780001000,
"tag": "0063_issue_thread_interactions",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1776780002000,
"tag": "0064_issue_thread_interaction_idempotency",
"breakpoints": true
}
]
}

View file

@ -35,6 +35,7 @@ export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js";
export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { issueThreadInteractions } from "./issue_thread_interactions.js";
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
export { issueInboxArchives } from "./issue_inbox_archives.js";
export { inboxDismissals } from "./inbox_dismissals.js";

View file

@ -0,0 +1,54 @@
import type {
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
} from "@paperclipai/shared";
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issueComments } from "./issue_comments.js";
import { issues } from "./issues.js";
export const issueThreadInteractions = pgTable(
"issue_thread_interactions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id),
kind: text("kind").notNull(),
status: text("status").notNull().default("pending"),
continuationPolicy: text("continuation_policy").notNull().default("wake_assignee"),
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),
createdByUserId: text("created_by_user_id"),
resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id),
resolvedByUserId: text("resolved_by_user_id"),
payload: jsonb("payload").$type<IssueThreadInteractionPayload>().notNull(),
result: jsonb("result").$type<IssueThreadInteractionResult>(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
issueIdx: index("issue_thread_interactions_issue_idx").on(table.issueId),
companyIssueCreatedAtIdx: index("issue_thread_interactions_company_issue_created_at_idx").on(
table.companyId,
table.issueId,
table.createdAt,
),
companyIssueStatusIdx: index("issue_thread_interactions_company_issue_status_idx").on(
table.companyId,
table.issueId,
table.status,
),
companyIssueIdempotencyUq: uniqueIndex("issue_thread_interactions_company_issue_idempotency_uq")
.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),
}),
);

View file

@ -63,6 +63,9 @@ Write tools:
- `paperclipCheckoutIssue`
- `paperclipReleaseIssue`
- `paperclipAddComment`
- `paperclipSuggestTasks`
- `paperclipAskUserQuestions`
- `paperclipRequestConfirmation`
- `paperclipUpsertIssueDocument`
- `paperclipRestoreIssueDocumentRevision`
- `paperclipControlIssueWorkspaceServices`

View file

@ -182,6 +182,90 @@ describe("paperclip MCP tools", () => {
expect(response.content[0]?.text).toContain("http://127.0.0.1:5173");
});
it("creates suggest_tasks interactions with the expected issue-scoped payload", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "interaction-1", kind: "suggest_tasks" }),
);
vi.stubGlobal("fetch", fetchMock);
const tool = getTool("paperclipSuggestTasks");
await tool.execute({
issueId: "PAP-1135",
idempotencyKey: "run-1:suggest",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions");
expect(init.method).toBe("POST");
expect(JSON.parse(String(init.body))).toEqual({
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
idempotencyKey: "run-1:suggest",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
});
});
it("creates request_confirmation interactions with plan target payloads", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "interaction-1", kind: "request_confirmation" }),
);
vi.stubGlobal("fetch", fetchMock);
const tool = getTool("paperclipRequestConfirmation");
await tool.execute({
issueId: "PAP-1135",
idempotencyKey: "confirmation:PAP-1135:plan:33333333-3333-4333-8333-333333333333",
title: "Plan approval",
payload: {
version: 1,
prompt: "Accept this plan?",
acceptLabel: "Accept plan",
allowDeclineReason: true,
rejectLabel: "Request changes",
rejectRequiresReason: true,
supersedeOnUserComment: true,
target: {
type: "issue_document",
key: "plan",
revisionId: "33333333-3333-4333-8333-333333333333",
revisionNumber: 3,
},
},
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions");
expect(init.method).toBe("POST");
expect(JSON.parse(String(init.body))).toEqual({
kind: "request_confirmation",
continuationPolicy: "none",
idempotencyKey: "confirmation:PAP-1135:plan:33333333-3333-4333-8333-333333333333",
title: "Plan approval",
payload: {
version: 1,
prompt: "Accept this plan?",
acceptLabel: "Accept plan",
allowDeclineReason: true,
rejectLabel: "Request changes",
rejectRequiresReason: true,
supersedeOnUserComment: true,
target: {
type: "issue_document",
key: "plan",
revisionId: "33333333-3333-4333-8333-333333333333",
revisionNumber: 3,
},
},
});
});
it("creates approvals with the expected company-scoped payload", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "approval-1" }),

View file

@ -1,9 +1,13 @@
import { z } from "zod";
import {
addIssueCommentSchema,
askUserQuestionsPayloadSchema,
checkoutIssueSchema,
createApprovalSchema,
createIssueSchema,
issueThreadInteractionContinuationPolicySchema,
requestConfirmationPayloadSchema,
suggestTasksPayloadSchema,
updateIssueSchema,
upsertIssueDocumentSchema,
linkIssueApprovalSchema,
@ -107,6 +111,39 @@ const addCommentToolSchema = z.object({
issueId: issueIdSchema,
}).merge(addIssueCommentSchema);
const createSuggestTasksToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: suggestTasksPayloadSchema,
});
const createAskUserQuestionsToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: askUserQuestionsPayloadSchema,
});
const createRequestConfirmationToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
payload: requestConfirmationPayloadSchema,
});
const approvalDecisionSchema = z.object({
approvalId: approvalIdSchema,
action: z.enum(["approve", "reject", "requestRevision", "resubmit"]),
@ -443,6 +480,42 @@ export function createToolDefinitions(client: PaperclipApiClient): ToolDefinitio
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/comments`, { body }),
),
makeTool(
"paperclipSuggestTasks",
"Create a suggest_tasks interaction on an issue",
createSuggestTasksToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "suggest_tasks",
...body,
},
}),
),
makeTool(
"paperclipAskUserQuestions",
"Create an ask_user_questions interaction on an issue",
createAskUserQuestionsToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "ask_user_questions",
...body,
},
}),
),
makeTool(
"paperclipRequestConfirmation",
"Create a request_confirmation interaction on an issue",
createRequestConfirmationToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "request_confirmation",
...body,
},
}),
),
makeTool(
"paperclipUpsertIssueDocument",
"Create or update an issue document",

View file

@ -184,6 +184,7 @@ export interface HostServices {
getOrchestrationSummary(params: WorkerToHostMethods["issues.summaries.getOrchestration"][0]): Promise<WorkerToHostMethods["issues.summaries.getOrchestration"][1]>;
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
createInteraction(params: WorkerToHostMethods["issues.createInteraction"][0]): Promise<WorkerToHostMethods["issues.createInteraction"][1]>;
};
/** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */
@ -342,6 +343,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"issues.summaries.getOrchestration": "issues.orchestration.read",
"issues.listComments": "issue.comments.read",
"issues.createComment": "issue.comments.create",
"issues.createInteraction": "issue.interactions.create",
// Issue Documents
"issues.documents.list": "issue.documents.read",
@ -575,6 +577,9 @@ export function createHostClientHandlers(
"issues.createComment": gated("issues.createComment", async (params) => {
return services.issues.createComment(params);
}),
"issues.createInteraction": gated("issues.createInteraction", async (params) => {
return services.issues.createInteraction(params);
}),
// Issue Documents
"issues.documents.list": gated("issues.documents.list", async (params) => {

View file

@ -27,6 +27,8 @@ import type {
IssueComment,
IssueDocument,
IssueDocumentSummary,
IssueThreadInteraction,
CreateIssueThreadInteraction,
Agent,
Goal,
} from "@paperclipai/shared";
@ -746,6 +748,15 @@ export interface WorkerToHostMethods {
params: { issueId: string; body: string; companyId: string; authorAgentId?: string },
result: IssueComment,
];
"issues.createInteraction": [
params: {
issueId: string;
companyId: string;
interaction: CreateIssueThreadInteraction;
authorAgentId?: string | null;
},
result: IssueThreadInteraction,
];
// Issue Documents
"issues.documents.list": [

View file

@ -8,6 +8,8 @@ import type {
Project,
Issue,
IssueComment,
IssueThreadInteraction,
CreateIssueThreadInteraction,
IssueDocument,
Agent,
Goal,
@ -149,6 +151,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const issues = new Map<string, Issue>();
const blockedByIssueIds = new Map<string, string[]>();
const issueComments = new Map<string, IssueComment[]>();
const issueInteractions = new Map<string, IssueThreadInteraction[]>();
const issueDocuments = new Map<string, IssueDocument>();
const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>();
@ -547,6 +550,50 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
issueComments.set(issueId, current);
return comment;
},
async createInteraction(issueId, interaction, companyId, options) {
requireCapability(manifest, capabilitySet, "issue.interactions.create");
const parentIssue = issues.get(issueId);
if (!isInCompany(parentIssue, companyId)) {
throw new Error(`Issue not found: ${issueId}`);
}
const now = new Date();
const current = issueInteractions.get(issueId) ?? [];
if (interaction.idempotencyKey) {
const existing = current.find((entry) => entry.idempotencyKey === interaction.idempotencyKey);
if (existing) return existing;
}
const created: IssueThreadInteraction = {
id: randomUUID(),
companyId: parentIssue.companyId,
issueId,
kind: interaction.kind,
status: "pending",
continuationPolicy: interaction.continuationPolicy ?? "wake_assignee",
idempotencyKey: interaction.idempotencyKey ?? null,
sourceCommentId: interaction.sourceCommentId ?? null,
sourceRunId: interaction.sourceRunId ?? null,
title: interaction.title ?? null,
summary: interaction.summary ?? null,
createdByAgentId: options?.authorAgentId ?? null,
createdByUserId: null,
payload: interaction.payload,
result: null,
createdAt: now,
updatedAt: now,
} as IssueThreadInteraction;
current.push(created);
issueInteractions.set(issueId, current);
return created;
},
async suggestTasks(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "suggest_tasks" }, companyId, options) as Promise<any>;
},
async askUserQuestions(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "ask_user_questions" }, companyId, options) as Promise<any>;
},
async requestConfirmation(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise<any>;
},
documents: {
async list(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");

View file

@ -22,6 +22,11 @@ import type {
IssueDocument,
IssueDocumentSummary,
IssueRelationIssueSummary,
IssueThreadInteraction,
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
Agent,
Goal,
@ -80,6 +85,11 @@ export type {
IssueDocument,
IssueDocumentSummary,
IssueRelationIssueSummary,
IssueThreadInteraction,
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
Agent,
Goal,
@ -1078,6 +1088,7 @@ export interface PluginIssueSummariesClient {
* - `issues.orchestration.read` for orchestration summaries
* - `issue.comments.read` for `listComments`
* - `issue.comments.create` for `createComment`
* - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, and `requestConfirmation`
* - `issue.documents.read` for `documents.list` and `documents.get`
* - `issue.documents.write` for `documents.upsert` and `documents.delete`
*/
@ -1182,6 +1193,30 @@ export interface PluginIssuesClient {
companyId: string,
options?: { authorAgentId?: string },
): Promise<IssueComment>;
createInteraction(
issueId: string,
interaction: CreateIssueThreadInteraction,
companyId: string,
options?: { authorAgentId?: string },
): Promise<IssueThreadInteraction>;
suggestTasks(
issueId: string,
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "suggest_tasks" }>, "kind">,
companyId: string,
options?: { authorAgentId?: string },
): Promise<SuggestTasksInteraction>;
askUserQuestions(
issueId: string,
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "ask_user_questions" }>, "kind">,
companyId: string,
options?: { authorAgentId?: string },
): Promise<AskUserQuestionsInteraction>;
requestConfirmation(
issueId: string,
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "request_confirmation" }>, "kind">,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestConfirmationInteraction>;
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
documents: PluginIssueDocumentsClient;
/** Read and write blocker relationships. */

View file

@ -38,7 +38,12 @@ import path from "node:path";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import type {
AskUserQuestionsInteraction,
PaperclipPluginManifestV1,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "@paperclipai/shared";
import type { PaperclipPlugin } from "./define-plugin.js";
import type {
@ -692,6 +697,66 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId });
},
async createInteraction(issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }) {
return callHost("issues.createInteraction", {
issueId,
companyId,
interaction,
authorAgentId: options?.authorAgentId,
});
},
async suggestTasks(
issueId: string,
interaction,
companyId: string,
options?: { authorAgentId?: string },
): Promise<SuggestTasksInteraction> {
return callHost("issues.createInteraction", {
issueId,
companyId,
interaction: {
...interaction,
kind: "suggest_tasks",
},
authorAgentId: options?.authorAgentId,
}) as Promise<SuggestTasksInteraction>;
},
async askUserQuestions(
issueId: string,
interaction,
companyId: string,
options?: { authorAgentId?: string },
): Promise<AskUserQuestionsInteraction> {
return callHost("issues.createInteraction", {
issueId,
companyId,
interaction: {
...interaction,
kind: "ask_user_questions",
},
authorAgentId: options?.authorAgentId,
}) as Promise<AskUserQuestionsInteraction>;
},
async requestConfirmation(
issueId: string,
interaction,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestConfirmationInteraction> {
return callHost("issues.createInteraction", {
issueId,
companyId,
interaction: {
...interaction,
kind: "request_confirmation",
},
authorAgentId: options?.authorAgentId,
}) as Promise<RequestConfirmationInteraction>;
},
documents: {
async list(issueId: string, companyId: string) {
return callHost("issues.documents.list", { issueId, companyId });

View file

@ -137,6 +137,31 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(","
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
export const ISSUE_THREAD_INTERACTION_KINDS = [
"suggest_tasks",
"ask_user_questions",
"request_confirmation",
] as const;
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
export const ISSUE_THREAD_INTERACTION_STATUSES = [
"pending",
"accepted",
"rejected",
"answered",
"expired",
"failed",
] as const;
export type IssueThreadInteractionStatus = (typeof ISSUE_THREAD_INTERACTION_STATUSES)[number];
export const ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES = [
"none",
"wake_assignee",
"wake_assignee_on_accept",
] as const;
export type IssueThreadInteractionContinuationPolicy =
(typeof ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES)[number];
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution"] as const;
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
@ -523,6 +548,7 @@ export const PLUGIN_CAPABILITIES = [
"issues.checkout",
"issues.wakeup",
"issue.comments.create",
"issue.interactions.create",
"issue.documents.write",
"agents.pause",
"agents.resume",

View file

@ -16,6 +16,9 @@ export {
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_ORIGIN_KINDS,
ISSUE_RELATION_TYPES,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
@ -105,6 +108,9 @@ export {
type AgentIconName,
type IssueStatus,
type IssuePriority,
type IssueThreadInteractionKind,
type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy,
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
@ -300,6 +306,28 @@ export type {
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment,
IssueThreadInteractionActorFields,
SuggestedTaskDraft,
SuggestTasksPayload,
SuggestTasksResultCreatedTask,
SuggestTasksResult,
AskUserQuestionsQuestionOption,
AskUserQuestionsQuestion,
AskUserQuestionsPayload,
AskUserQuestionsAnswer,
AskUserQuestionsResult,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationCustomTarget,
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
IssueThreadInteractionBase,
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
IssueDocument,
IssueDocumentSummary,
DocumentRevision,
@ -555,6 +583,27 @@ export {
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
addIssueCommentSchema,
issueThreadInteractionStatusSchema,
issueThreadInteractionKindSchema,
issueThreadInteractionContinuationPolicySchema,
suggestedTaskDraftSchema,
suggestTasksPayloadSchema,
suggestTasksResultCreatedTaskSchema,
suggestTasksResultSchema,
askUserQuestionsQuestionOptionSchema,
askUserQuestionsQuestionSchema,
askUserQuestionsPayloadSchema,
askUserQuestionsAnswerSchema,
askUserQuestionsResultSchema,
requestConfirmationIssueDocumentTargetSchema,
requestConfirmationCustomTargetSchema,
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
respondIssueThreadInteractionSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
createIssueWorkProductSchema,
@ -580,6 +629,10 @@ export {
type UpdateIssue,
type CheckoutIssue,
type AddIssueComment,
type CreateIssueThreadInteraction,
type AcceptIssueThreadInteraction,
type RejectIssueThreadInteraction,
type RespondIssueThreadInteraction,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
type CreateIssueWorkProduct,

View file

@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { createIssueThreadInteractionSchema } from "./validators/issue.js";
describe("issue thread interaction schemas", () => {
it("parses request_confirmation payloads with default no-wake continuation", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Revise",
rejectRequiresReason: true,
rejectReasonLabel: "What needs to change?",
declineReasonPlaceholder: "Optional: tell the agent what you'd change.",
detailsMarkdown: "The current plan document will be accepted as-is.",
supersedeOnUserComment: true,
},
});
expect(parsed).toMatchObject({
kind: "request_confirmation",
continuationPolicy: "none",
payload: {
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Revise",
rejectRequiresReason: true,
rejectReasonLabel: "What needs to change?",
allowDeclineReason: true,
declineReasonPlaceholder: "Optional: tell the agent what you'd change.",
supersedeOnUserComment: true,
},
});
});
it("accepts issue document targets for request_confirmation interactions", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation",
continuationPolicy: "wake_assignee_on_accept",
payload: {
version: 1,
prompt: "Accept the latest plan revision?",
allowDeclineReason: false,
target: {
type: "issue_document",
issueId: "11111111-1111-4111-8111-111111111111",
documentId: "22222222-2222-4222-8222-222222222222",
key: "plan",
revisionId: "33333333-3333-4333-8333-333333333333",
revisionNumber: 2,
label: "Plan v2",
href: "/issues/PAP-123#document-plan",
},
},
});
expect(parsed.kind).toBe("request_confirmation");
if (parsed.kind !== "request_confirmation") return;
expect(parsed.payload.target).toMatchObject({
type: "issue_document",
key: "plan",
revisionNumber: 2,
label: "Plan v2",
href: "/issues/PAP-123#document-plan",
});
});
it("accepts custom targets for request_confirmation interactions", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the external checklist?",
target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
revisionNumber: 1,
label: "Checklist v1",
href: "https://example.com/checklist",
},
},
});
expect(parsed.kind).toBe("request_confirmation");
if (parsed.kind !== "request_confirmation") return;
expect(parsed.payload.target).toMatchObject({
type: "custom",
key: "external-checklist",
label: "Checklist v1",
});
});
it("rejects unsafe request_confirmation target hrefs", () => {
const base = {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed?",
target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
label: "Checklist v1",
},
},
} as const;
for (const href of ["javascript:alert(1)", "data:text/html,hi", "//evil.example/path"]) {
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
target: {
...base.payload.target,
href,
},
},
})).toThrow("href must not use javascript:, data:, or protocol-relative URLs");
}
});
});

View file

@ -114,6 +114,28 @@ export type {
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment,
IssueThreadInteractionActorFields,
SuggestedTaskDraft,
SuggestTasksPayload,
SuggestTasksResultCreatedTask,
SuggestTasksResult,
AskUserQuestionsQuestionOption,
AskUserQuestionsQuestion,
AskUserQuestionsPayload,
AskUserQuestionsAnswer,
AskUserQuestionsResult,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationCustomTarget,
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
IssueThreadInteractionBase,
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
IssueDocument,
IssueDocumentSummary,
DocumentRevision,

View file

@ -6,6 +6,9 @@ import type {
IssueExecutionStateStatus,
IssueOriginKind,
IssuePriority,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionKind,
IssueThreadInteractionStatus,
IssueStatus,
} from "../constants.js";
import type { Goal } from "./goal.js";
@ -263,6 +266,180 @@ export interface IssueComment {
updatedAt: Date;
}
export interface IssueThreadInteractionActorFields {
createdByAgentId?: string | null;
createdByUserId?: string | null;
resolvedByAgentId?: string | null;
resolvedByUserId?: string | null;
}
export interface SuggestedTaskDraft {
clientKey: string;
parentClientKey?: string | null;
parentId?: string | null;
title: string;
description?: string | null;
priority?: IssuePriority | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
projectId?: string | null;
goalId?: string | null;
billingCode?: string | null;
labels?: string[];
hiddenInPreview?: boolean;
}
export interface SuggestTasksPayload {
version: 1;
defaultParentId?: string | null;
tasks: SuggestedTaskDraft[];
}
export interface SuggestTasksResultCreatedTask {
clientKey: string;
issueId: string;
identifier?: string | null;
title?: string | null;
parentIssueId?: string | null;
parentIdentifier?: string | null;
}
export interface SuggestTasksResult {
version: 1;
createdTasks?: SuggestTasksResultCreatedTask[];
skippedClientKeys?: string[];
rejectionReason?: string | null;
}
export interface AskUserQuestionsQuestionOption {
id: string;
label: string;
description?: string | null;
}
export interface AskUserQuestionsQuestion {
id: string;
prompt: string;
helpText?: string | null;
selectionMode: "single" | "multi";
required?: boolean;
options: AskUserQuestionsQuestionOption[];
}
export interface AskUserQuestionsPayload {
version: 1;
title?: string | null;
submitLabel?: string | null;
questions: AskUserQuestionsQuestion[];
}
export interface AskUserQuestionsAnswer {
questionId: string;
optionIds: string[];
}
export interface AskUserQuestionsResult {
version: 1;
answers: AskUserQuestionsAnswer[];
summaryMarkdown?: string | null;
}
export interface RequestConfirmationIssueDocumentTarget {
type: "issue_document";
issueId?: string | null;
documentId?: string | null;
key: string;
revisionId: string;
revisionNumber?: number | null;
label?: string | null;
href?: string | null;
}
export interface RequestConfirmationCustomTarget {
type: "custom";
key: string;
revisionId?: string | null;
revisionNumber?: number | null;
label?: string | null;
href?: string | null;
}
export type RequestConfirmationTarget =
| RequestConfirmationIssueDocumentTarget
| RequestConfirmationCustomTarget;
export interface RequestConfirmationPayload {
version: 1;
prompt: string;
acceptLabel?: string | null;
rejectLabel?: string | null;
rejectRequiresReason?: boolean;
rejectReasonLabel?: string | null;
allowDeclineReason?: boolean;
declineReasonPlaceholder?: string | null;
detailsMarkdown?: string | null;
supersedeOnUserComment?: boolean;
target?: RequestConfirmationTarget | null;
}
export interface RequestConfirmationResult {
version: 1;
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
reason?: string | null;
commentId?: string | null;
staleTarget?: RequestConfirmationTarget | null;
}
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
id: string;
companyId: string;
issueId: string;
kind: IssueThreadInteractionKind;
idempotencyKey?: string | null;
sourceCommentId?: string | null;
sourceRunId?: string | null;
title?: string | null;
summary?: string | null;
status: IssueThreadInteractionStatus;
continuationPolicy: IssueThreadInteractionContinuationPolicy;
createdAt: Date | string;
updatedAt: Date | string;
resolvedAt?: Date | string | null;
}
export interface SuggestTasksInteraction extends IssueThreadInteractionBase {
kind: "suggest_tasks";
payload: SuggestTasksPayload;
result?: SuggestTasksResult | null;
}
export interface AskUserQuestionsInteraction extends IssueThreadInteractionBase {
kind: "ask_user_questions";
payload: AskUserQuestionsPayload;
result?: AskUserQuestionsResult | null;
}
export interface RequestConfirmationInteraction extends IssueThreadInteractionBase {
kind: "request_confirmation";
payload: RequestConfirmationPayload;
result?: RequestConfirmationResult | null;
}
export type IssueThreadInteraction =
| SuggestTasksInteraction
| AskUserQuestionsInteraction
| RequestConfirmationInteraction;
export type IssueThreadInteractionPayload =
| SuggestTasksPayload
| AskUserQuestionsPayload
| RequestConfirmationPayload;
export type IssueThreadInteractionResult =
| SuggestTasksResult
| AskUserQuestionsResult
| RequestConfirmationResult;
export interface IssueAttachment {
id: string;
companyId: string;

View file

@ -142,6 +142,27 @@ export {
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
addIssueCommentSchema,
issueThreadInteractionStatusSchema,
issueThreadInteractionKindSchema,
issueThreadInteractionContinuationPolicySchema,
suggestedTaskDraftSchema,
suggestTasksPayloadSchema,
suggestTasksResultCreatedTaskSchema,
suggestTasksResultSchema,
askUserQuestionsQuestionOptionSchema,
askUserQuestionsQuestionSchema,
askUserQuestionsPayloadSchema,
askUserQuestionsAnswerSchema,
askUserQuestionsResultSchema,
requestConfirmationIssueDocumentTargetSchema,
requestConfirmationCustomTargetSchema,
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
respondIssueThreadInteractionSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
issueDocumentFormatSchema,
@ -155,6 +176,10 @@ export {
type IssueExecutionWorkspaceSettings,
type CheckoutIssue,
type AddIssueComment,
type CreateIssueThreadInteraction,
type AcceptIssueThreadInteraction,
type RejectIssueThreadInteraction,
type RespondIssueThreadInteraction,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
type IssueDocumentFormat,

View file

@ -6,6 +6,9 @@ import {
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_PRIORITIES,
ISSUE_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
} from "../constants.js";
export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [
@ -183,6 +186,254 @@ export const addIssueCommentSchema = z.object({
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 issueThreadInteractionContinuationPolicySchema = z.enum(
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
);
export const issueDocumentKeySchema = z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -");
export const suggestedTaskDraftSchema = z.object({
clientKey: z.string().trim().min(1).max(120),
parentClientKey: z.string().trim().min(1).max(120).nullable().optional(),
parentId: z.string().uuid().nullable().optional(),
title: z.string().trim().min(1).max(240),
description: z.string().trim().max(20000).nullable().optional(),
priority: z.enum(ISSUE_PRIORITIES).nullable().optional(),
assigneeAgentId: z.string().uuid().nullable().optional(),
assigneeUserId: z.string().trim().min(1).nullable().optional(),
projectId: z.string().uuid().nullable().optional(),
goalId: z.string().uuid().nullable().optional(),
billingCode: z.string().trim().max(120).nullable().optional(),
labels: z.array(z.string().trim().min(1).max(48)).max(20).optional(),
hiddenInPreview: z.boolean().optional(),
}).superRefine((value, ctx) => {
if (value.assigneeAgentId && value.assigneeUserId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Suggested tasks can only target one assignee",
path: ["assigneeAgentId"],
});
}
});
export const suggestTasksPayloadSchema = z.object({
version: z.literal(1),
defaultParentId: z.string().uuid().nullable().optional(),
tasks: z.array(suggestedTaskDraftSchema).min(1).max(50),
}).superRefine((value, ctx) => {
const seenClientKeys = new Set<string>();
for (const [index, task] of value.tasks.entries()) {
if (seenClientKeys.has(task.clientKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "clientKey must be unique within one interaction",
path: ["tasks", index, "clientKey"],
});
continue;
}
seenClientKeys.add(task.clientKey);
}
});
export const suggestTasksResultCreatedTaskSchema = z.object({
clientKey: z.string().trim().min(1).max(120),
issueId: z.string().uuid(),
identifier: z.string().trim().min(1).nullable().optional(),
title: z.string().trim().min(1).nullable().optional(),
parentIssueId: z.string().uuid().nullable().optional(),
parentIdentifier: z.string().trim().min(1).nullable().optional(),
});
export const suggestTasksResultSchema = z.object({
version: z.literal(1),
createdTasks: z.array(suggestTasksResultCreatedTaskSchema).max(50).optional(),
skippedClientKeys: z.array(z.string().trim().min(1).max(120)).max(50).optional(),
rejectionReason: z.string().trim().max(4000).nullable().optional(),
});
export const askUserQuestionsQuestionOptionSchema = z.object({
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullable().optional(),
});
export const askUserQuestionsQuestionSchema = z.object({
id: z.string().trim().min(1).max(120),
prompt: z.string().trim().min(1).max(500),
helpText: z.string().trim().max(1000).nullable().optional(),
selectionMode: z.enum(["single", "multi"]),
required: z.boolean().optional(),
options: z.array(askUserQuestionsQuestionOptionSchema).min(1).max(10),
});
export const askUserQuestionsPayloadSchema = z.object({
version: z.literal(1),
title: z.string().trim().max(240).nullable().optional(),
submitLabel: z.string().trim().max(120).nullable().optional(),
questions: z.array(askUserQuestionsQuestionSchema).min(1).max(10),
}).superRefine((value, ctx) => {
const seenQuestionIds = new Set<string>();
for (const [questionIndex, question] of value.questions.entries()) {
if (seenQuestionIds.has(question.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Question ids must be unique within one interaction",
path: ["questions", questionIndex, "id"],
});
}
seenQuestionIds.add(question.id);
const seenOptionIds = new Set<string>();
for (const [optionIndex, option] of question.options.entries()) {
if (seenOptionIds.has(option.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Option ids must be unique within one question",
path: ["questions", questionIndex, "options", optionIndex, "id"],
});
}
seenOptionIds.add(option.id);
}
}
});
export const askUserQuestionsAnswerSchema = z.object({
questionId: z.string().trim().min(1).max(120),
optionIds: z.array(z.string().trim().min(1).max(120)).max(20),
});
export const askUserQuestionsResultSchema = z.object({
version: z.literal(1),
answers: z.array(askUserQuestionsAnswerSchema).max(20),
summaryMarkdown: z.string().max(20000).nullable().optional(),
});
const requestConfirmationHrefSchema = z.string().trim().min(1).max(2000).refine((value) => {
const lower = value.toLowerCase();
return !lower.startsWith("javascript:")
&& !lower.startsWith("data:")
&& !value.startsWith("//");
}, "href must not use javascript:, data:, or protocol-relative URLs");
const requestConfirmationTargetBaseSchema = z.object({
label: z.string().trim().min(1).max(120).nullable().optional(),
href: requestConfirmationHrefSchema.nullable().optional(),
});
export const requestConfirmationIssueDocumentTargetSchema = requestConfirmationTargetBaseSchema.extend({
type: z.literal("issue_document"),
issueId: z.string().uuid().nullable().optional(),
documentId: z.string().uuid().nullable().optional(),
key: issueDocumentKeySchema,
revisionId: z.string().uuid(),
revisionNumber: z.number().int().positive().nullable().optional(),
});
export const requestConfirmationCustomTargetSchema = requestConfirmationTargetBaseSchema.extend({
type: z.literal("custom"),
key: z.string().trim().min(1).max(120),
revisionId: z.string().trim().min(1).max(255).nullable().optional(),
revisionNumber: z.number().int().positive().nullable().optional(),
});
export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [
requestConfirmationIssueDocumentTargetSchema,
requestConfirmationCustomTargetSchema,
]);
export const requestConfirmationPayloadSchema = z.object({
version: z.literal(1),
prompt: z.string().trim().min(1).max(1000),
acceptLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectRequiresReason: z.boolean().optional(),
rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
allowDeclineReason: z.boolean().optional().default(true),
declineReasonPlaceholder: z.string().trim().min(1).max(240).nullable().optional(),
detailsMarkdown: z.string().max(20000).nullable().optional(),
supersedeOnUserComment: z.boolean().optional(),
target: requestConfirmationTargetSchema.nullable().optional(),
});
export const requestConfirmationResultSchema = z.object({
version: z.literal(1),
outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]),
reason: z.string().trim().max(4000).nullable().optional(),
commentId: z.string().uuid().nullable().optional(),
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
});
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("suggest_tasks"),
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: suggestTasksPayloadSchema,
}),
z.object({
kind: z.literal("ask_user_questions"),
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: askUserQuestionsPayloadSchema,
}),
z.object({
kind: z.literal("request_confirmation"),
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
payload: requestConfirmationPayloadSchema,
}),
]);
export type CreateIssueThreadInteraction = z.infer<typeof createIssueThreadInteractionSchema>;
export const acceptIssueThreadInteractionSchema = z.object({
selectedClientKeys: z.array(z.string().trim().min(1).max(120)).min(1).max(50).optional(),
}).superRefine((value, ctx) => {
const seenClientKeys = new Set<string>();
for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) {
if (seenClientKeys.has(clientKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "selectedClientKeys must be unique",
path: ["selectedClientKeys", index],
});
continue;
}
seenClientKeys.add(clientKey);
}
});
export type AcceptIssueThreadInteraction = z.infer<typeof acceptIssueThreadInteractionSchema>;
export const rejectIssueThreadInteractionSchema = z.object({
reason: z.string().trim().max(4000).optional(),
});
export type RejectIssueThreadInteraction = z.infer<typeof rejectIssueThreadInteractionSchema>;
export const respondIssueThreadInteractionSchema = z.object({
answers: z.array(askUserQuestionsAnswerSchema).max(20),
summaryMarkdown: z.string().max(20000).nullable().optional(),
});
export type RespondIssueThreadInteraction = z.infer<typeof respondIssueThreadInteractionSchema>;
export const linkIssueApprovalSchema = z.object({
approvalId: z.string().uuid(),
});
@ -199,13 +450,6 @@ export const ISSUE_DOCUMENT_FORMATS = ["markdown"] as const;
export const issueDocumentFormatSchema = z.enum(ISSUE_DOCUMENT_FORMATS);
export const issueDocumentKeySchema = z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -");
export const upsertIssueDocumentSchema = z.object({
title: z.string().trim().max(200).nullable().optional(),
format: issueDocumentFormatSchema,