diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index cb89012f3ef..3cde5554a77 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -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: { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 31c24dcb0ee..b4fbe42dfa6 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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; diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index a139c92044c..bfb3f51387d 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -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) { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 058bf5ad501..cadb2d04683 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -108,6 +108,7 @@ interface IssueChatMessageContext { feedbackDataSharingPreference: FeedbackDataSharingPreference; feedbackTermsUrl: string | null; agentMap?: Map; + projectNameMap?: ReadonlyMap; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; userProfileMap?: ReadonlyMap | null; @@ -245,6 +246,7 @@ interface IssueChatThreadProps { projectId?: string | null; issueStatus?: string; agentMap?: Map; + projectNameMap?: ReadonlyMap; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; userProfileMap?: ReadonlyMap | 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 }) { { 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: { diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index c65f8b4f2f9..a75d7dddc20 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -29,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; interface IssueThreadInteractionCardProps { interaction: IssueThreadInteraction; agentMap?: Map; + projectNameMap?: ReadonlyMap; currentUserId?: string | null; userLabelMap?: ReadonlyMap | 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; agentMap?: Map; + projectNameMap?: ReadonlyMap; currentUserId?: string | null; userLabelMap?: ReadonlyMap | 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 ? ( ) : null} - {node.task.projectId ? ( - + {projectLabel ? ( + ) : null} {labels.map((label) => ( @@ -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; + projectNameMap?: ReadonlyMap; currentUserId?: string | null; userLabelMap?: ReadonlyMap | 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({ ; + projectNameMap: ReadonlyMap; currentUserId: string | null; userLabelMap: ReadonlyMap | null; userProfileMap: ReadonlyMap | 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(); + 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}