[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

@ -2299,6 +2299,35 @@ async function resolveInviteResolutionTarget(
url: URL
): Promise<ResolvedInviteResolutionTarget> {
const hostname = hostnameForResolution(url);
if (parseIpv4Address(hostname)) {
if (!isPublicIpAddress(hostname)) {
throw badRequest(
"url resolves to a private, local, multicast, or reserved address"
);
}
return {
url,
resolvedAddress: hostname,
resolvedAddresses: [hostname],
hostHeader: url.host,
tlsServername: undefined,
};
}
const literalIpVersion = isIP(hostname);
if (literalIpVersion !== 0) {
if (!isPublicIpAddress(hostname)) {
throw badRequest(
"url resolves to a private, local, multicast, or reserved address"
);
}
return {
url,
resolvedAddress: hostname,
resolvedAddresses: [hostname],
hostHeader: url.host,
tlsServername: undefined,
};
}
const results = await lookupInviteResolutionHostname(hostname);
if (results.length === 0) {
throw badRequest("url hostname did not resolve to any addresses");

View file

@ -6,7 +6,9 @@ import type { Db } from "@paperclipai/db";
import { issueExecutionDecisions } from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
createIssueAttachmentMetadataSchema,
createIssueThreadInteractionSchema,
createIssueWorkProductSchema,
createIssueLabelSchema,
checkoutIssueSchema,
@ -19,7 +21,9 @@ import {
linkIssueApprovalSchema,
issueDocumentKeySchema,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
rejectIssueThreadInteractionSchema,
restoreIssueDocumentRevisionSchema,
respondIssueThreadInteractionSchema,
updateIssueWorkProductSchema,
upsertIssueDocumentSchema,
updateIssueSchema,
@ -40,6 +44,7 @@ import {
heartbeatService,
instanceSettingsService,
issueApprovalService,
issueThreadInteractionService,
ISSUE_LIST_DEFAULT_LIMIT,
ISSUE_LIST_MAX_LIMIT,
issueReferenceService,
@ -53,7 +58,7 @@ import {
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectIssueWorkspaceCommandPaths,
@ -185,6 +190,65 @@ function shouldImplicitlyMoveCommentedIssueToTodoForAgent(input: {
return true;
}
function queueResolvedInteractionContinuationWakeup(input: {
heartbeat: ReturnType<typeof heartbeatService>;
issue: { id: string; assigneeAgentId: string | null; status: string };
interaction: {
id: string;
kind: string;
status: string;
continuationPolicy: string;
sourceCommentId?: string | null;
sourceRunId?: string | null;
};
actor: { actorType: "user" | "agent"; actorId: string };
source: string;
}) {
if (
input.interaction.continuationPolicy !== "wake_assignee"
&& input.interaction.continuationPolicy !== "wake_assignee_on_accept"
) return;
if (
input.interaction.continuationPolicy === "wake_assignee_on_accept"
&& input.interaction.status !== "accepted"
) return;
if (input.interaction.status === "expired") return;
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
void input.heartbeat.wakeup(input.issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: input.issue.id,
interactionId: input.interaction.id,
interactionKind: input.interaction.kind,
interactionStatus: input.interaction.status,
sourceCommentId: input.interaction.sourceCommentId ?? null,
sourceRunId: input.interaction.sourceRunId ?? null,
mutation: "interaction",
},
requestedByActorType: input.actor.actorType,
requestedByActorId: input.actor.actorId,
contextSnapshot: {
issueId: input.issue.id,
taskId: input.issue.id,
interactionId: input.interaction.id,
interactionKind: input.interaction.kind,
interactionStatus: input.interaction.status,
sourceCommentId: input.interaction.sourceCommentId ?? null,
sourceRunId: input.interaction.sourceRunId ?? null,
wakeReason: "issue_commented",
source: input.source,
},
}).catch((err) => logger.warn({
err,
issueId: input.issue.id,
interactionId: input.interaction.id,
agentId: input.issue.assigneeAgentId,
}, "failed to wake assignee on issue interaction resolution"));
}
function diffExecutionParticipants(
previousPolicy: NormalizedExecutionPolicy | null,
nextPolicy: NormalizedExecutionPolicy | null,
@ -351,6 +415,34 @@ export function issueRoutes(
return value === true || value === "true" || value === "1";
}
async function logExpiredRequestConfirmations(input: {
issue: { id: string; companyId: string; identifier?: string | null };
interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>;
actor: ReturnType<typeof getActorInfo>;
source: string;
}) {
for (const interaction of input.interactions) {
await logActivity(db, {
companyId: input.issue.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
action: "issue.thread_interaction_expired",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier ?? null,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
source: input.source,
result: interaction.result ?? null,
},
});
}
}
function parseDateQuery(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = new Date(value);
@ -1041,6 +1133,28 @@ export function issueRoutes(
},
});
if (!result.created) {
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
{
id: doc.id,
key: doc.key,
latestRevisionId: doc.latestRevisionId,
latestRevisionNumber: doc.latestRevisionNumber,
},
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.document_updated",
});
}
res.status(result.created ? 201 : 200).json(doc);
});
@ -1118,6 +1232,26 @@ export function issueRoutes(
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
{
id: result.document.id,
key: result.document.key,
latestRevisionId: result.document.latestRevisionId,
latestRevisionNumber: result.document.latestRevisionNumber,
},
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.document_restored",
});
res.json(result.document);
},
);
@ -1169,6 +1303,25 @@ export function issueRoutes(
}),
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
{
id: removed.id,
key: removed.key,
latestRevisionId: null,
latestRevisionNumber: null,
},
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.document_deleted",
});
res.json({ ok: true });
});
@ -2032,6 +2185,21 @@ export function issueRoutes(
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment(
issue,
comment,
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.comment",
});
} else if (updateReferenceSummaryAfter) {
issueResponse = {
...issueResponse,
@ -2440,6 +2608,269 @@ export function issueRoutes(
res.json(comments);
});
router.get("/issues/:id/interactions", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const interactions = await issueThreadInteractionService(db).listForIssue(id);
res.json(interactions);
});
router.post("/issues/:id/interactions", validate(createIssueThreadInteractionSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type === "agent") {
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
} else {
assertBoard(req);
}
const actor = getActorInfo(req);
const agentSourceRunId = req.actor.type === "agent" ? requireAgentRunId(req, res) : null;
if (req.actor.type === "agent" && !agentSourceRunId) return;
const interaction = await issueThreadInteractionService(db).create(issue, {
...req.body,
sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null,
}, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.thread_interaction_created",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
continuationPolicy: interaction.continuationPolicy,
},
});
res.status(201).json(interaction);
});
router.post(
"/issues/:id/interactions/:interactionId/accept",
validate(acceptIssueThreadInteractionSchema),
async (req, res) => {
const id = req.params.id as string;
const interactionId = req.params.interactionId as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
assertBoard(req);
const actor = getActorInfo(req);
const { interaction, createdIssues, continuationIssue } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
const continuationWakeIssue = continuationIssue ?? issue;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: interaction.status === "expired"
? "issue.thread_interaction_expired"
: "issue.thread_interaction_accepted",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
createdTaskCount:
interaction.kind === "suggest_tasks"
? (interaction.result?.createdTasks?.length ?? 0)
: 0,
skippedTaskCount:
interaction.kind === "suggest_tasks"
? (interaction.result?.skippedClientKeys?.length ?? 0)
: 0,
},
});
if (continuationIssue) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
status: continuationIssue.status,
assigneeAgentId: continuationIssue.assigneeAgentId ?? null,
assigneeUserId: continuationIssue.assigneeUserId ?? null,
source: "request_confirmation_accept",
interactionId: interaction.id,
_previous: {
status: issue.status,
assigneeAgentId: issue.assigneeAgentId ?? null,
assigneeUserId: issue.assigneeUserId ?? null,
},
},
});
}
for (const createdIssue of createdIssues) {
void queueIssueAssignmentWakeup({
heartbeat,
issue: createdIssue,
reason: "issue_assigned",
mutation: "interaction_accept",
contextSource: "issue.interaction.accept",
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
}
queueResolvedInteractionContinuationWakeup({
heartbeat,
issue: continuationWakeIssue,
interaction,
actor,
source: "issue.interaction.accept",
});
res.json(interaction);
},
);
router.post(
"/issues/:id/interactions/:interactionId/reject",
validate(rejectIssueThreadInteractionSchema),
async (req, res) => {
const id = req.params.id as string;
const interactionId = req.params.interactionId as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
assertBoard(req);
const actor = getActorInfo(req);
const interaction = await issueThreadInteractionService(db).rejectInteraction(issue, interactionId, req.body, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: interaction.status === "expired"
? "issue.thread_interaction_expired"
: "issue.thread_interaction_rejected",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
rejectionReason:
interaction.kind === "suggest_tasks"
? (interaction.result?.rejectionReason ?? null)
: interaction.kind === "request_confirmation"
? (interaction.result?.reason ?? null)
: null,
},
});
queueResolvedInteractionContinuationWakeup({
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.reject",
});
res.json(interaction);
},
);
router.post(
"/issues/:id/interactions/:interactionId/respond",
validate(respondIssueThreadInteractionSchema),
async (req, res) => {
const id = req.params.id as string;
const interactionId = req.params.interactionId as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
assertBoard(req);
const actor = getActorInfo(req);
const interaction = await issueThreadInteractionService(db).answerQuestions(issue, interactionId, req.body, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.thread_interaction_answered",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
answeredQuestionCount:
interaction.kind === "ask_user_questions"
? (interaction.result?.answers?.length ?? 0)
: 0,
},
});
queueResolvedInteractionContinuationWakeup({
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.respond",
});
res.json(interaction);
},
);
router.get("/issues/:id/comments/:commentId", async (req, res) => {
const id = req.params.id as string;
const commentId = req.params.commentId as string;
@ -2737,6 +3168,21 @@ export function issueRoutes(
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment(
currentIssue,
comment,
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue: currentIssue,
interactions: expiredInteractions,
actor,
source: "issue.comment",
});
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();