mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Address interaction review follow-ups
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
33525a209e
commit
aa5c37e8e7
7 files changed
+115
-10
No files matched your search
@@ -360,6 +360,56 @@ describe("issue thread interaction routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("answers questions and emits an accept-only continuation wake", async () => {
|
||||
mockInteractionService.answerQuestions.mockResolvedValueOnce({
|
||||
id: "interaction-2",
|
||||
companyId: "company-1",
|
||||
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
kind: "ask_user_questions",
|
||||
status: "answered",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
idempotencyKey: null,
|
||||
sourceCommentId: "comment-2",
|
||||
sourceRunId: "run-2",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [{
|
||||
id: "scope",
|
||||
prompt: "Scope?",
|
||||
selectionMode: "single",
|
||||
options: [{ id: "phase-1", label: "Phase 1" }],
|
||||
}],
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
|
||||
},
|
||||
createdAt: "2026-04-20T12:00:00.000Z",
|
||||
updatedAt: "2026-04-20T12:06:00.000Z",
|
||||
resolvedAt: "2026-04-20T12:06:00.000Z",
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
|
||||
.send({
|
||||
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
payload: expect.objectContaining({
|
||||
interactionId: "interaction-2",
|
||||
interactionKind: "ask_user_questions",
|
||||
interactionStatus: "answered",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts request confirmations and wakes the current assignee when configured for accept-only wakeups", async () => {
|
||||
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
||||
interaction: {
|
||||
|
||||
@@ -204,13 +204,18 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
actor: { actorType: "user" | "agent"; actorId: string };
|
||||
source: string;
|
||||
}) {
|
||||
const isAcceptResolution = input.interaction.status === "accepted"
|
||||
|| (
|
||||
input.interaction.kind === "ask_user_questions"
|
||||
&& input.interaction.status === "answered"
|
||||
);
|
||||
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"
|
||||
&& !isAcceptResolution
|
||||
) return;
|
||||
if (input.interaction.status === "expired") return;
|
||||
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
|
||||
|
||||
@@ -731,7 +731,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
||||
switch (current.kind) {
|
||||
case "suggest_tasks":
|
||||
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
|
||||
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor, current);
|
||||
case "request_confirmation": {
|
||||
const accepted = await acceptRequestConfirmation({
|
||||
issue,
|
||||
@@ -754,12 +754,14 @@ export function issueThreadInteractionService(db: Db) {
|
||||
interactionId: string,
|
||||
input: AcceptIssueThreadInteraction,
|
||||
actor: InteractionActor,
|
||||
currentRow?: IssueThreadInteractionRow,
|
||||
) => {
|
||||
const current = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const current = currentRow
|
||||
?? await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!current) throw notFound("Interaction not found");
|
||||
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
|
||||
@@ -108,6 +108,7 @@ interface IssueChatMessageContext {
|
||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||
feedbackTermsUrl: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
projectNameMap?: ReadonlyMap<string, string>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
@@ -245,6 +246,7 @@ interface IssueChatThreadProps {
|
||||
projectId?: string | null;
|
||||
issueStatus?: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
projectNameMap?: ReadonlyMap<string, string>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
@@ -1734,6 +1736,7 @@ function IssueChatFeedbackButtons({
|
||||
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
const {
|
||||
agentMap,
|
||||
projectNameMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
onAcceptInteraction,
|
||||
@@ -1769,6 +1772,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={agentMap}
|
||||
projectNameMap={projectNameMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
@@ -2152,6 +2156,7 @@ export function IssueChatThread({
|
||||
projectId,
|
||||
issueStatus,
|
||||
agentMap,
|
||||
projectNameMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
@@ -2350,6 +2355,7 @@ export function IssueChatThread({
|
||||
feedbackDataSharingPreference,
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
projectNameMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
@@ -2370,6 +2376,7 @@ export function IssueChatThread({
|
||||
feedbackDataSharingPreference,
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
projectNameMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
|
||||
@@ -99,6 +99,26 @@ describe("IssueThreadInteractionCard", () => {
|
||||
expect(host.textContent).toContain("Child task");
|
||||
});
|
||||
|
||||
it("renders project names for suggested task metadata", () => {
|
||||
const host = renderCard({
|
||||
interaction: {
|
||||
...pendingSuggestedTasksInteraction,
|
||||
payload: {
|
||||
...pendingSuggestedTasksInteraction.payload,
|
||||
tasks: pendingSuggestedTasksInteraction.payload.tasks.map((task) =>
|
||||
task.clientKey === "root-design"
|
||||
? { ...task, projectId: "project-board-ui" }
|
||||
: task
|
||||
),
|
||||
},
|
||||
},
|
||||
projectNameMap: new Map([["project-board-ui", "Board UI"]]),
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("Project: Board UI");
|
||||
expect(host.textContent).not.toContain("Project: project-board-ui");
|
||||
});
|
||||
|
||||
it("shows an explicit placeholder when a rejected interaction has no reason", () => {
|
||||
const host = renderCard({
|
||||
interaction: {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
interface IssueThreadInteractionCardProps {
|
||||
interaction: IssueThreadInteraction;
|
||||
agentMap?: Map<string, Agent>;
|
||||
projectNameMap?: ReadonlyMap<string, string>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
onAcceptInteraction?: (
|
||||
@@ -171,6 +172,7 @@ function TaskTreeNode({
|
||||
node,
|
||||
createdByClientKey,
|
||||
agentMap,
|
||||
projectNameMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
depth = 0,
|
||||
@@ -182,6 +184,7 @@ function TaskTreeNode({
|
||||
node: SuggestedTaskTreeNode;
|
||||
createdByClientKey: ReadonlyMap<string, SuggestTasksResultCreatedTask>;
|
||||
agentMap?: Map<string, Agent>;
|
||||
projectNameMap?: ReadonlyMap<string, string>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
depth?: number;
|
||||
@@ -208,9 +211,12 @@ function TaskTreeNode({
|
||||
node.task.assigneeAgentId || node.task.assigneeUserId,
|
||||
);
|
||||
const labels = node.task.labels ?? [];
|
||||
const projectLabel = node.task.projectId
|
||||
? projectNameMap?.get(node.task.projectId) ?? node.task.projectId.slice(0, 8)
|
||||
: null;
|
||||
const hasMetadata = hasExplicitAssignee
|
||||
|| Boolean(node.task.billingCode)
|
||||
|| Boolean(node.task.projectId)
|
||||
|| Boolean(projectLabel)
|
||||
|| labels.length > 0;
|
||||
|
||||
return (
|
||||
@@ -282,8 +288,8 @@ function TaskTreeNode({
|
||||
{node.task.billingCode ? (
|
||||
<TaskField label="Billing" value={node.task.billingCode} />
|
||||
) : null}
|
||||
{node.task.projectId ? (
|
||||
<TaskField label="Project" value={node.task.projectId} tone="subtle" />
|
||||
{projectLabel ? (
|
||||
<TaskField label="Project" value={projectLabel} tone="subtle" />
|
||||
) : null}
|
||||
{labels.map((label) => (
|
||||
<TaskField key={label} label="Label" value={label} tone="subtle" />
|
||||
@@ -311,6 +317,7 @@ function TaskTreeNode({
|
||||
node={child}
|
||||
createdByClientKey={createdByClientKey}
|
||||
agentMap={agentMap}
|
||||
projectNameMap={projectNameMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
depth={depth + 1}
|
||||
@@ -329,6 +336,7 @@ function TaskTreeNode({
|
||||
function SuggestTasksCard({
|
||||
interaction,
|
||||
agentMap,
|
||||
projectNameMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
onAcceptInteraction,
|
||||
@@ -336,6 +344,7 @@ function SuggestTasksCard({
|
||||
}: {
|
||||
interaction: SuggestTasksInteraction;
|
||||
agentMap?: Map<string, Agent>;
|
||||
projectNameMap?: ReadonlyMap<string, string>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
onAcceptInteraction?: (
|
||||
@@ -458,6 +467,7 @@ function SuggestTasksCard({
|
||||
node={root}
|
||||
createdByClientKey={createdByClientKey}
|
||||
agentMap={agentMap}
|
||||
projectNameMap={projectNameMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
selectedClientKeys={selectedClientKeys}
|
||||
@@ -1157,6 +1167,7 @@ function RequestConfirmationCard({
|
||||
export function IssueThreadInteractionCard({
|
||||
interaction,
|
||||
agentMap,
|
||||
projectNameMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
onAcceptInteraction,
|
||||
@@ -1238,6 +1249,7 @@ export function IssueThreadInteractionCard({
|
||||
<SuggestTasksCard
|
||||
interaction={interaction}
|
||||
agentMap={agentMap}
|
||||
projectNameMap={projectNameMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
|
||||
@@ -523,6 +523,7 @@ type IssueDetailChatTabProps = {
|
||||
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
|
||||
feedbackTermsUrl: string | null;
|
||||
agentMap: Map<string, Agent>;
|
||||
projectNameMap: ReadonlyMap<string, string>;
|
||||
currentUserId: string | null;
|
||||
userLabelMap: ReadonlyMap<string, string> | null;
|
||||
userProfileMap: ReadonlyMap<string, import("../lib/company-members").CompanyUserProfile> | null;
|
||||
@@ -573,6 +574,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
feedbackDataSharingPreference,
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
projectNameMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
@@ -736,6 +738,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
projectId={projectId}
|
||||
issueStatus={issueStatus}
|
||||
agentMap={agentMap}
|
||||
projectNameMap={projectNameMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
userProfileMap={userProfileMap}
|
||||
@@ -1152,6 +1155,11 @@ export function IssueDetail() {
|
||||
for (const a of agents ?? []) map.set(a.id, a);
|
||||
return map;
|
||||
}, [agents]);
|
||||
const projectNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const project of orderedProjects) map.set(project.id, project.name);
|
||||
return map;
|
||||
}, [orderedProjects]);
|
||||
const userProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
@@ -2931,6 +2939,7 @@ export function IssueDetail() {
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
||||
agentMap={agentMap}
|
||||
projectNameMap={projectNameMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
userProfileMap={userProfileMap}
|
||||
|
||||
Reference in new issue
Block a user