[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

@ -12,6 +12,10 @@ import {
resolveAssistantMessageFoldedState,
resolveIssueChatHumanAuthor,
} from "./IssueChatThread";
import type {
AskUserQuestionsInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
const { markdownEditorFocusMock } = vi.hoisted(() => ({
markdownEditorFocusMock: vi.fn(),
@ -139,6 +143,78 @@ vi.mock("../hooks/usePaperclipIssueRuntime", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createSuggestedTasksInteraction(
overrides: Partial<SuggestTasksInteraction> = {},
): SuggestTasksInteraction {
return {
id: "interaction-suggest-1",
companyId: "company-1",
issueId: "issue-1",
kind: "suggest_tasks",
title: "Suggested follow-up work",
summary: "Preview the next issue tree before accepting it.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
tasks: [
{
clientKey: "task-1",
title: "Prototype the card",
},
],
},
result: null,
...overrides,
};
}
function createQuestionInteraction(
overrides: Partial<AskUserQuestionsInteraction> = {},
): AskUserQuestionsInteraction {
return {
id: "interaction-question-1",
companyId: "company-1",
issueId: "issue-1",
kind: "ask_user_questions",
title: "Clarify the phase",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:03:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
submitLabel: "Submit answers",
questions: [
{
id: "scope",
prompt: "Pick one scope",
selectionMode: "single",
required: true,
options: [
{ id: "phase-1", label: "Phase 1" },
{ id: "phase-2", label: "Phase 2" },
],
},
],
},
result: null,
...overrides,
};
}
describe("IssueChatThread", () => {
let container: HTMLDivElement;
@ -300,6 +376,165 @@ describe("IssueChatThread", () => {
});
});
it("invokes the accept callback for pending suggested-task interactions", async () => {
const root = createRoot(container);
const onAcceptInteraction = vi.fn(async () => undefined);
await act(async () => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
interactions={[createSuggestedTasksInteraction()]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onAcceptInteraction={onAcceptInteraction}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const acceptButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Accept drafts"),
);
expect(acceptButton).toBeTruthy();
await act(async () => {
acceptButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({
id: "interaction-suggest-1",
kind: "suggest_tasks",
}),
["task-1"],
);
act(() => {
root.unmount();
});
});
it("submits only the selected draft subtree when tasks are manually pruned", async () => {
const root = createRoot(container);
const onAcceptInteraction = vi.fn(async () => undefined);
await act(async () => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
interactions={[createSuggestedTasksInteraction({
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Root task",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Child task",
},
],
},
})]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onAcceptInteraction={onAcceptInteraction}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const childCheckbox = container.querySelector('[aria-label="Include Child task"]');
expect(childCheckbox).toBeTruthy();
await act(async () => {
childCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const acceptButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Accept selected drafts"),
);
expect(acceptButton).toBeTruthy();
await act(async () => {
acceptButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({
id: "interaction-suggest-1",
kind: "suggest_tasks",
}),
["root"],
);
act(() => {
root.unmount();
});
});
it("submits selected answers for pending question interactions", async () => {
const root = createRoot(container);
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
await act(async () => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
interactions={[createQuestionInteraction()]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const optionButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Phase 1"),
);
const submitButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Submit answers"),
);
expect(optionButton).toBeTruthy();
expect(submitButton).toBeTruthy();
await act(async () => {
optionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
expect.objectContaining({
id: "interaction-question-1",
kind: "ask_user_questions",
}),
[{ questionId: "scope", optionIds: ["phase-1"] }],
);
act(() => {
root.unmount();
});
});
it("renders the transcript directly from stable Paperclip messages", () => {
const root = createRoot(container);

View file

@ -45,6 +45,14 @@ import {
type IssueChatTranscriptEntry,
type SegmentTiming,
} from "../lib/issue-chat-messages";
import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
IssueThreadInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
import { isIssueThreadInteraction } from "../lib/issue-thread-interactions";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { Button } from "@/components/ui/button";
@ -67,6 +75,7 @@ import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
import { AgentIcon } from "./AgentIconPicker";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import {
@ -114,6 +123,18 @@ interface IssueChatMessageContext {
onCancelQueued?: (commentId: string) => void;
interruptingQueuedRunId?: string | null;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
selectedClientKeys?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
}
const IssueChatCtx = createContext<IssueChatMessageContext>({
@ -211,6 +232,7 @@ interface IssueChatComposerProps {
interface IssueChatThreadProps {
comments: IssueChatComment[];
interactions?: IssueThreadInteraction[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
@ -256,6 +278,18 @@ interface IssueChatThreadProps {
interruptingQueuedRunId?: string | null;
stoppingRunId?: string | null;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
selectedClientKeys?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
composerRef?: Ref<IssueChatComposerHandle>;
}
@ -1698,7 +1732,14 @@ function IssueChatFeedbackButtons({
}
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx);
const {
agentMap,
currentUserId,
userLabelMap,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
} = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
const runId = typeof custom.runId === "string" ? custom.runId : null;
@ -1717,6 +1758,27 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
to: IssueTimelineAssignee;
}
: null;
const interaction = isIssueThreadInteraction(custom.interaction)
? custom.interaction
: null;
if (custom.kind === "interaction" && interaction) {
return (
<div id={anchorId}>
<div className="py-1.5">
<IssueThreadInteractionCard
interaction={interaction}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
/>
</div>
</div>
);
}
if (custom.kind === "event" && actorName) {
const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId;
@ -2077,6 +2139,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
export function IssueChatThread({
comments,
interactions = [],
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
@ -2118,6 +2181,9 @@ export function IssueChatThread({
interruptingQueuedRunId = null,
stoppingRunId = null,
onImageClick,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
composerRef,
}: IssueChatThreadProps) {
const location = useLocation();
@ -2173,6 +2239,7 @@ export function IssueChatThread({
() =>
buildIssueChatMessages({
comments,
interactions,
timelineEvents,
linkedRuns,
liveRuns,
@ -2188,6 +2255,7 @@ export function IssueChatThread({
}),
[
comments,
interactions,
timelineEvents,
linkedRuns,
liveRuns,
@ -2256,7 +2324,14 @@ export function IssueChatThread({
useEffect(() => {
const hash = location.hash;
if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return;
if (
!(
hash.startsWith("#comment-")
|| hash.startsWith("#activity-")
|| hash.startsWith("#run-")
|| hash.startsWith("#interaction-")
)
) return;
if (messages.length === 0 || hasScrolledRef.current) return;
const targetId = hash.slice(1);
const element = document.getElementById(targetId);
@ -2286,6 +2361,9 @@ export function IssueChatThread({
onCancelQueued,
interruptingQueuedRunId,
onImageClick,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
}),
[
feedbackVoteByTargetId,
@ -2303,6 +2381,9 @@ export function IssueChatThread({
onCancelQueued,
interruptingQueuedRunId,
onImageClick,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
],
);

View file

@ -0,0 +1,258 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
import { ThemeProvider } from "../context/ThemeContext";
import { TooltipProvider } from "./ui/tooltip";
import {
pendingAskUserQuestionsInteraction,
commentExpiredRequestConfirmationInteraction,
disabledDeclineReasonRequestConfirmationInteraction,
failedRequestConfirmationInteraction,
pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction,
staleTargetRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
} from "../fixtures/issueThreadInteractionFixtures";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("@/lib/router", () => ({
Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
}));
function renderCard(
props: Partial<ComponentProps<typeof IssueThreadInteractionCard>> = {},
) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<TooltipProvider>
<ThemeProvider>
<IssueThreadInteractionCard
interaction={pendingAskUserQuestionsInteraction}
{...props}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
return container;
}
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
container?.remove();
root = null;
container = null;
});
describe("IssueThreadInteractionCard", () => {
it("exposes pending question options as selectable radio and checkbox controls", () => {
const host = renderCard({
interaction: pendingAskUserQuestionsInteraction,
onSubmitInteractionAnswers: vi.fn(),
});
const singleGroup = host.querySelector('[role="radiogroup"]');
expect(singleGroup?.getAttribute("aria-labelledby")).toBe(
"interaction-questions-default-collapse-depth-prompt",
);
const radios = [...host.querySelectorAll('[role="radio"]')];
expect(radios).toHaveLength(2);
expect(radios[0]?.getAttribute("aria-checked")).toBe("false");
act(() => {
(radios[0] as HTMLButtonElement).click();
});
expect(radios[0]?.getAttribute("aria-checked")).toBe("true");
expect(radios[1]?.getAttribute("aria-checked")).toBe("false");
const multiGroup = host.querySelector('[role="group"]');
expect(multiGroup?.getAttribute("aria-labelledby")).toBe(
"interaction-questions-default-post-submit-summary-prompt",
);
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3);
});
it("makes child tasks explicit in suggested task trees", () => {
const host = renderCard({
interaction: pendingSuggestedTasksInteraction,
});
expect(host.textContent).toContain("Child task");
});
it("shows an explicit placeholder when a rejected interaction has no reason", () => {
const host = renderCard({
interaction: {
...rejectedSuggestedTasksInteraction,
result: { version: 1 },
},
});
expect(host.textContent).toContain("No reason provided.");
});
it("requires a decline reason when the request confirmation payload asks for one", async () => {
const onRejectInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingRequestConfirmationInteraction,
onRejectInteraction,
});
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Request revisions"),
);
expect(declineButton).toBeTruthy();
await act(async () => {
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("Request revisions"),
).at(-1);
expect(saveButton?.hasAttribute("disabled")).toBe(false);
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).toContain("A decline reason is required.");
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
expect(textarea?.getAttribute("aria-invalid")).toBe("true");
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(textarea, "Needs a smaller phase split");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const enabledSaveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("Request revisions"),
).at(-1);
expect(enabledSaveButton?.hasAttribute("disabled")).toBe(false);
await act(async () => {
enabledSaveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onRejectInteraction).toHaveBeenCalledWith(
expect.objectContaining({ kind: "request_confirmation" }),
"Needs a smaller phase split",
);
});
it("invokes the confirm callback with pending request confirmations", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingRequestConfirmationInteraction,
onAcceptInteraction,
});
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Approve plan"),
);
expect(confirmButton).toBeTruthy();
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ kind: "request_confirmation" }),
);
});
it("labels accept-only continuation policies in the card header", () => {
const host = renderCard({
interaction: {
...pendingRequestConfirmationInteraction,
continuationPolicy: "wake_assignee_on_accept",
},
});
expect(host.textContent).toContain("Wakes on confirm");
});
it("renders request confirmation target links and stale-target expiry", () => {
const host = renderCard({
interaction: staleTargetRequestConfirmationInteraction,
});
const targetLinks = host.querySelectorAll("a");
expect(host.textContent).toContain("Expired by target change");
expect(host.textContent).toContain("Plan v3");
expect(host.textContent).toContain("Plan v4");
expect(targetLinks[0]?.getAttribute("href")).toContain("#document-plan");
expect(targetLinks[1]?.getAttribute("href")).toContain("#document-plan");
expect(host.textContent).not.toContain("Approve plan");
});
it("renders a jump link for confirmations expired by comment", () => {
const host = renderCard({
interaction: commentExpiredRequestConfirmationInteraction,
});
const jumpLink = Array.from(host.querySelectorAll("a")).find((link) =>
link.textContent?.includes("Jump to comment"),
);
expect(jumpLink?.getAttribute("href")).toBe(
"#comment-22222222-2222-4222-8222-222222222222",
);
});
it("declines immediately when decline reasons are disabled", async () => {
const onRejectInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: disabledDeclineReasonRequestConfirmationInteraction,
onRejectInteraction,
});
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Keep it"),
);
expect(declineButton).toBeTruthy();
await act(async () => {
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.querySelector("textarea")).toBeNull();
expect(onRejectInteraction).toHaveBeenCalledWith(
expect.objectContaining({ kind: "request_confirmation" }),
undefined,
);
});
it("renders explicit copy for failed request confirmations", () => {
const host = renderCard({
interaction: failedRequestConfirmationInteraction,
});
expect(host.textContent).toContain(
"This request could not be resolved. Try again or create a new request.",
);
});
});

File diff suppressed because it is too large Load diff