diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 1b5563bd74e..cae9b747bba 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1101,6 +1101,8 @@ export interface IssueQueuedCommentEntry { interactionId: string; interactionKind: string; requiresFreshSession?: boolean; + /** Readable one-line status, such as "Declined: Slack connection". */ + summary?: string; }; comment: IssueComment; position: number; diff --git a/server/src/__tests__/issue-queued-comments-routes.test.ts b/server/src/__tests__/issue-queued-comments-routes.test.ts index 6c49b8a6db2..4d045bb25cc 100644 --- a/server/src/__tests__/issue-queued-comments-routes.test.ts +++ b/server/src/__tests__/issue-queued-comments-routes.test.ts @@ -1846,4 +1846,108 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { .get(`/api/issues/${seeded.issueId}/queued-comments`); expect(after.body.entries.map((entry: any) => entry.comment.id)).toEqual(seeded.commentIds); }); + // BUG-08: a declined connection request (Slack) queued its read-only receipt + // ahead of the user's next message. The receipt showed raw JSON, could not be + // edited or discarded, and the next message never appeared in the queue. + async function seedDeclinedConnectionReceipt(runState: "running" | "legacy" | "stopped") { + const seeded = await seedQueue(); + const interactionId = randomUUID(); + await db.insert(issueThreadInteractions).values({ + id: interactionId, companyId: seeded.companyId, issueId: seeded.issueId, + kind: "connection_intent", status: "rejected", createdByAgentId: seeded.agentId, + sourceRunId: seeded.runId, resolvedByUserId: "queue-owner", resolvedAt: new Date(), + continuationPolicy: "wake_assignee", requestedResolverPolicy: "human_only", + effectiveResolverPolicy: "human_only", resolverPolicyProvenance: "explicit", + title: "Connect Slack", summary: "The agent needs this connection to continue.", + payload: { version: 1, serviceSlug: "slack", serviceName: "Slack", phase: "requested", + requestingAgentId: seeded.agentId, requestingAgentName: "Paperclip Runner" }, + result: { version: 1, outcome: "declined", reason: null }, + }); + // Same receipt shape as wakeConnectionIntentAfterResolution. + await db.update(agentWakeupRequests).set({ payload: { issueId: seeded.issueId, + mutation: "interaction", interactionId, interactionKind: "connection_intent", interactionStatus: "rejected", + _paperclipWakeContext: { issueId: seeded.issueId, interactionId, interactionKind: "connection_intent", + interactionStatus: "rejected", mutation: "interaction", refreshTools: true } }, + }).where(eq(agentWakeupRequests.id, seeded.wakeId)); + if (runState === "legacy") { + // A busy legacy run defers each new message behind the issue execution. + await db.update(agents).set({ adapterType: "claude_local", + runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } }).where(eq(agents.id, seeded.agentId)); + await db.update(heartbeatRuns).set({ runtimeMode: "legacy" }).where(eq(heartbeatRuns.id, seeded.runId)); + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); + testProcesses.set(seeded.runId, child); + runningProcesses.set(seeded.runId, { child, graceSec: 1, processGroupId: null }); + } + if (runState === "stopped") { + await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(), errorCode: "workspace_busy" }) + .where(eq(heartbeatRuns.id, seeded.runId)); + await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, seeded.issueId)); + // Occupy the agent on another task so admission holds new work without + // invoking a provider (the staging agent was busy on ONB-2/ONB-3). + await db.update(agents).set({ runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } }) + .where(eq(agents.id, seeded.agentId)); + await db.insert(heartbeatRuns).values({ companyId: seeded.companyId, agentId: seeded.agentId, + status: "running", contextSnapshot: { issueId: randomUUID() } }); + } + return { ...seeded, interactionId }; + } + + it("shows a declined connection receipt as a readable status, not its JSON", async () => { + const seeded = await seedDeclinedConnectionReceipt("running"); + const queue = await request(app(seeded.companyId)).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200); + expect(queue.body.entries).toHaveLength(1); + const [entry] = queue.body.entries; + expect(entry.source).toMatchObject({ kind: "interaction", interactionKind: "connection_intent", + summary: "Declined: Slack connection" }); + // The agent still receives the typed receipt in the steering body. + expect(entry.comment.body).toContain('"outcome": "declined"'); + }); + + it.each(["running", "stopped"] as const)( + "keeps the next message visible and actionable after a declined connection (%s run)", + async (runState) => { + const seeded = await seedDeclinedConnectionReceipt(runState); + const client = app(seeded.companyId); + const sent = await request(client).post(`/api/issues/${seeded.issueId}/comments`) + .send({ body: "Skip Slack for now and continue" }).expect(201); + await vi.waitFor(async () => expect(await db.select().from(agentWakeupRequests)).toHaveLength(2)); + const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200); + const next = queue.body.entries.find((entry: any) => entry.comment.id === sent.body.id); + expect(next, JSON.stringify(queue.body.entries.map((entry: any) => entry.comment.body.split("\n")[0]))) + .toMatchObject({ canEdit: true, canDiscard: true }); + expect(next.source).toBeUndefined(); + expect(queue.body.queueId).not.toBe(seeded.wakeId); + // The message is the user's own queue entry, so its controls work. + await request(client).delete(`/api/issues/${seeded.issueId}/queued-comments/${sent.body.id}`) + .send({ queueId: queue.body.queueId, revision: queue.body.revision }).expect(200); + // Declining the connection still reaches the agent on its own receipt. + const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId)); + expect(receipt.status).toBe("deferred_issue_execution"); + await new Promise(resolve => setTimeout(resolve, 250)); + }, + ); + + it.each(["legacy", "stopped"] as const)( + "queues every later message together after a declined connection (%s run)", + async (runState) => { + const seeded = await seedDeclinedConnectionReceipt(runState); + const client = app(seeded.companyId); + const first = await request(client).post(`/api/issues/${seeded.issueId}/comments`) + .send({ body: "Skip Slack for now" }).expect(201); + await vi.waitFor(async () => expect(await db.select().from(agentWakeupRequests)).toHaveLength(2)); + const second = await request(client).post(`/api/issues/${seeded.issueId}/comments`) + .send({ body: "Use email updates instead" }).expect(201); + await vi.waitFor(async () => { + const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200); + expect(queue.body.entries.map((entry: any) => entry.comment.id)).toEqual([first.body.id, second.body.id]); + }); + const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200); + expect(queue.body.entries.every((entry: any) => entry.canEdit && entry.canDiscard)).toBe(true); + // The receipt is never merged with user input; it keeps its own wake. + const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId)); + expect(receipt.status).toBe("deferred_issue_execution"); + expect(receipt.payload?._paperclipWakeContext).not.toHaveProperty("wakeCommentIds"); + await new Promise(resolve => setTimeout(resolve, 250)); + }, + ); }); diff --git a/server/src/modules/wake-queue/adapters/postgres.ts b/server/src/modules/wake-queue/adapters/postgres.ts index 121befc8345..2161b80f28c 100644 --- a/server/src/modules/wake-queue/adapters/postgres.ts +++ b/server/src/modules/wake-queue/adapters/postgres.ts @@ -929,6 +929,11 @@ export function createWakeAdmissionReader(): WakeAdmissionReader { eq(agentWakeupRequests.agentId, agentId), eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS), sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`, + // A saved card response (interaction receipt) never absorbs user + // input. Skip it so a later message joins the existing message + // wake instead of starting another one behind the receipt. + sql`${agentWakeupRequests.payload} ->> 'interactionId' is null`, + sql`${agentWakeupRequests.payload} -> ${DEFERRED_WAKE_CONTEXT_KEY}::text ->> 'interactionId' is null`, ...(chatConversationId ? [ sql`coalesce(${agentWakeupRequests.payload} -> ${DEFERRED_WAKE_CONTEXT_KEY}::text ->> ${CHAT_CONVERSATION_ID_KEY}::text, ${chatConversationId}) = ${chatConversationId}`, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 16ff5201e4b..f7b5513d3fb 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -7442,13 +7442,20 @@ export function issueRoutes( ) .orderBy(asc(agentWakeupRequests.requestedAt)); + // A saved card response (an approval, an answer, a declined connection) + // is a read-only receipt. It must never hide the user's own later + // messages, which are separately queued and remain editable and sendable. + let receipt: IssueQueueState | null = null; for (const wake of rows) { if ( readObject(wake.payload).issueId !== issue.id || (queuedCommentIdsFromWakePayload(wake.payload).length === 0 && !queuedInteractionId(wake.payload)) ) continue; + const isReceipt = Boolean(queuedInteractionId(wake.payload)); + if (isReceipt && receipt) continue; if (wake.status === "deferred_issue_execution") { + if (isReceipt) { receipt = { wake, state: "deferred", queueRun: null }; continue; } return { wake, state: "deferred", queueRun: null }; } if (!wake.runId) continue; @@ -7466,9 +7473,10 @@ export function issueRoutes( ) .limit(1) .then((runRows) => runRows[0] ?? null); - if (queueRun) return { wake, state: "queued", queueRun }; + if (queueRun && isReceipt) receipt = { wake, state: "queued", queueRun }; + else if (queueRun) return { wake, state: "queued", queueRun }; } - return null; + return receipt; } async function queueCommentsForWake( diff --git a/server/src/services/queued-interaction-response.ts b/server/src/services/queued-interaction-response.ts index ec0794a80b5..553ed0efdaa 100644 --- a/server/src/services/queued-interaction-response.ts +++ b/server/src/services/queued-interaction-response.ts @@ -16,6 +16,24 @@ export function queuedInteractionId(payload: unknown): string | null { ? p.interactionId : null; } +/** + * One readable line for a queued response. The steering body also carries the + * typed JSON receipt for the agent; people only ever see this line. + */ +export function queuedInteractionSummary(interaction: { + kind: string; status: string; title?: string | null; payload?: unknown; +}): string { + const p = record(interaction.payload); + if (interaction.kind === "connection_intent") { + const service = typeof p.serviceName === "string" && p.serviceName.trim() ? p.serviceName.trim() : null; + const subject = service ? `${service} connection` : interaction.title ?? "Connection request"; + return interaction.status === "rejected" ? `Declined: ${subject}` : `Connected: ${subject}`; + } + const label = interaction.status === "accepted" ? "Accepted" : interaction.status === "rejected" ? "Rejected" : "Answered"; + const subject = interaction.title ?? (typeof p.prompt === "string" ? p.prompt : null) ?? "Questions"; + return `${label}: ${subject}`; +} + export async function readQueuedInteractionResponse(db: Db, companyId: string, issueId: string, payload: unknown) { const id = queuedInteractionId(payload); if (!id) return null; @@ -27,8 +45,7 @@ export async function readQueuedInteractionResponse(db: Db, companyId: string, i if (!interaction || interaction.status !== record(payload).interactionStatus) return null; const p = record(interaction.payload); const result = record(interaction.result); - const label = interaction.status === "accepted" ? "Accepted" : interaction.status === "rejected" ? "Rejected" : "Answered"; - const heading = `${label}: ${interaction.title ?? p.prompt ?? "Questions"}`; + const heading = queuedInteractionSummary(interaction); // Preserve the question labels, selected options, custom answers, approval // target and exact revision. This is also the explicit steering message. const body = `${heading}\n\n${JSON.stringify({ @@ -44,6 +61,7 @@ export async function readQueuedInteractionResponse(db: Db, companyId: string, i const source: NonNullable = { kind: "interaction", interactionId: interaction.id, interactionKind: interaction.kind, requiresFreshSession: record(record(payload)._paperclipWakeContext).forceFreshSession === true, + summary: heading, }; return { comment, source }; } diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx index f5284d14b8b..0423904878d 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx @@ -126,6 +126,27 @@ describe("TaskChatQueuedMessages", () => { else expect(props.onInterrupt).toHaveBeenCalledOnce(); }); + it("shows a declined connection request as readable text, never its raw JSON receipt (BUG-08)", () => { + const receipt = JSON.stringify({ interactionId: "slack-1", kind: "connection_intent", status: "rejected", + payload: { serviceSlug: "slack", serviceName: "Slack" }, result: { outcome: "declined" } }, null, 2); + const entry = { ...queue.entries[0], canEdit: false, canDiscard: false, + comment: { ...queue.entries[0].comment, id: "slack-1", body: `Rejected: Connect Slack\n\n${receipt}` }, + source: { kind: "interaction" as const, interactionId: "slack-1", interactionKind: "connection_intent", + summary: "Declined: Slack connection" } }; + render({ queue: { ...queue, entries: [entry], steeringDisposition: "temporarily_unavailable" } }); + const row = container.querySelector('[data-testid="task-chat-queued-message-slack-1"]')!; + const hoverAndLabels = [...row.querySelectorAll("[title], [aria-label]")] + .flatMap((element) => [element.getAttribute("title"), element.getAttribute("aria-label")]) + .filter((text): text is string => Boolean(text)); + expect(hoverAndLabels.length).toBeGreaterThan(0); + for (const text of hoverAndLabels) { + expect(text).not.toContain("{"); + expect(text).not.toContain("interactionId"); + } + expect(row.textContent).toContain("Declined: Slack connection"); + expect(row.querySelector('[title="Declined: Slack connection"]')).not.toBeNull(); + }); + it("reorders the complete queue and rewrites contiguous positions", () => { const next = reorderQueuedMessageEntries( queue.entries, diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx index f4bc42deded..a35eff3b80d 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx @@ -54,6 +54,16 @@ export function reorderQueuedMessageEntries( })); } +/** + * People see one readable line. A saved card response also carries a typed + * JSON receipt for the agent; it never reaches a label or a hover. + */ +export function queuedMessageLabel(entry: IssueQueuedCommentEntry): string { + if (entry.source?.kind !== "interaction") return entry.comment.body; + const summary = entry.source.summary?.trim(); + return summary || entry.comment.body.split("\n")[0] || "Saved response"; +} + function queueActionErrorCode(error: unknown): string | null { if (typeof error !== "object" || error === null) return null; const body = (error as { body?: unknown }).body; @@ -97,7 +107,7 @@ function SortableQueuedMessage({ onDiscard: () => void; }) { const immutableResponse = entry.source?.kind === "interaction"; - const label = immutableResponse ? entry.comment.body.split("\n")[0] : entry.comment.body; + const label = queuedMessageLabel(entry); const sortable = useSortable({ id: entry.comment.id, disabled: queueMutationDisabled || immutableResponse, @@ -132,7 +142,7 @@ function SortableQueuedMessage({ {...sortable.attributes} {...sortable.listeners} disabled={queueMutationDisabled || immutableResponse} - aria-label={`Reorder queued message: ${entry.comment.body}`} + aria-label={`Reorder queued message: ${label}`} className="flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-40" > @@ -142,7 +152,7 @@ function SortableQueuedMessage({ className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden /> - + {label} @@ -190,7 +200,7 @@ function SortableQueuedMessage({ !entry.canDiscard } title="Discard queued message" - aria-label={`Discard queued message: ${entry.comment.body}`} + aria-label={`Discard queued message: ${label}`} className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40" data-testid={`task-chat-queued-discard-${entry.comment.id}`} > @@ -207,7 +217,7 @@ function SortableQueuedMessage({ type="button" disabled={queueMutationDisabled || immutableResponse} title="Queued message actions" - aria-label={`Queued message actions: ${entry.comment.body}`} + aria-label={`Queued message actions: ${label}`} className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40" >