mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[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:
parent
014aa0eb2d
commit
a957394420
93 changed files with 10089 additions and 752 deletions
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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\"}.",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
54
packages/db/src/schema/issue_thread_interactions.ts
Normal file
54
packages/db/src/schema/issue_thread_interactions.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
|
|
@ -63,6 +63,9 @@ Write tools:
|
|||
- `paperclipCheckoutIssue`
|
||||
- `paperclipReleaseIssue`
|
||||
- `paperclipAddComment`
|
||||
- `paperclipSuggestTasks`
|
||||
- `paperclipAskUserQuestions`
|
||||
- `paperclipRequestConfirmation`
|
||||
- `paperclipUpsertIssueDocument`
|
||||
- `paperclipRestoreIssueDocumentRevision`
|
||||
- `paperclipControlIssueWorkspaceServices`
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
123
packages/shared/src/issue-thread-interactions.test.ts
Normal file
123
packages/shared/src/issue-thread-interactions.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue