Address interaction review follow-ups

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-21 20:18:56 -05:00
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: {
+6 -1
View File
@@ -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) {
+7
View File
@@ -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}
+9
View File
@@ -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}