diff --git a/server/src/__tests__/issue-queued-comments-routes.test.ts b/server/src/__tests__/issue-queued-comments-routes.test.ts index 1aa33edb2c1..442dbeb0390 100644 --- a/server/src/__tests__/issue-queued-comments-routes.test.ts +++ b/server/src/__tests__/issue-queued-comments-routes.test.ts @@ -1539,7 +1539,7 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { expect(Object.keys(edited.body.entries[0]).sort()).toEqual(Object.keys(initial.body.entries[0]).sort()); }); - it("cancels the deferred wake when the final message is discarded before promotion", async () => { + it.each(["discard", "cancel"] as const)("cancels the deferred wake when the final message is discarded before promotion (%s)", async (mode) => { const seeded = await seedQueue(); await db.delete(issueComments).where(eq(issueComments.id, seeded.commentIds[1])); await db @@ -1559,7 +1559,9 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { const initial = await request(app(seeded.companyId)) .get(`/api/issues/${seeded.issueId}/queued-comments`); const discarded = await request(app(seeded.companyId)) - .delete(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) + .delete(mode === "cancel" + ? `/api/issues/${seeded.issueId}/comments/${seeded.commentIds[0]}?mode=cancel` + : `/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) .send({ queueId: seeded.wakeId, revision: initial.body.revision }); expect(discarded.status, JSON.stringify(discarded.body)).toBe(200); const [wake, runs] = await Promise.all([ @@ -1573,9 +1575,9 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { expect(runs.map((run) => run.id)).toEqual([seeded.runId]); }); - it("routes legacy queued-comment cancellation through the promoted queue", async () => { + it.each([false, true])("routes comment cancellation through the queue (promoted=%s)", async (promoted) => { const seeded = await seedQueue(); - const queueRunId = await promoteQueue(seeded); + const queueRunId = promoted ? await promoteQueue(seeded) : null; const cancelled = await request(app(seeded.companyId)) .delete(`/api/issues/${seeded.issueId}/comments/${seeded.commentIds[0]}?mode=cancel`); expect(cancelled.status, JSON.stringify(cancelled.body)).toBe(200); @@ -1585,20 +1587,23 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { .from(agentWakeupRequests) .where(eq(agentWakeupRequests.id, seeded.wakeId)) .then((rows) => rows[0]), - db.select({ contextSnapshot: heartbeatRuns.contextSnapshot }) + queueRunId ? db.select({ contextSnapshot: heartbeatRuns.contextSnapshot }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, queueRunId)) - .then((rows) => rows[0]), + .then((rows) => rows[0]) : null, ]); expect((wake?.payload as any)?._paperclipWakeContext?.wakeCommentIds).toEqual([ seeded.commentIds[1], ]); - expect((queueRun?.contextSnapshot as any)?.wakeCommentIds).toEqual([ + if (promoted) expect((queueRun?.contextSnapshot as any)?.wakeCommentIds).toEqual([ seeded.commentIds[1], ]); + expect(await db.select().from(issueComments).where(eq(issueComments.id, seeded.commentIds[0]))).toEqual([]); + const remaining = await request(app(seeded.companyId)).get(`/api/issues/${seeded.issueId}/queued-comments`); + expect(remaining.body.entries.map((entry: any) => entry.comment.id)).toEqual([seeded.commentIds[1]]); }); - it("reports an explicit conflict after queued-run dispatch has begun", async () => { + it.each(["discard", "cancel"] as const)("reports an explicit conflict after queued-run dispatch has begun (%s)", async (mode) => { const seeded = await seedQueue(); const initial = await request(app(seeded.companyId)) .get(`/api/issues/${seeded.issueId}/queued-comments`); @@ -1613,7 +1618,9 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { .where(eq(heartbeatRuns.id, queueRunId)); const discard = await request(app(seeded.companyId)) - .delete(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) + .delete(mode === "cancel" + ? `/api/issues/${seeded.issueId}/comments/${seeded.commentIds[0]}?mode=cancel` + : `/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) .send({ queueId: seeded.wakeId, revision: initial.body.revision }); expect(discard.status).toBe(409); expect(discard.body.details?.code).toBe("queued_comment_already_dispatching"); @@ -1625,7 +1632,7 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { expect(comment?.id).toBe(seeded.commentIds[0]); }); - it("limits edit and trash to the comment owner", async () => { + it.each(["discard", "cancel"] as const)("limits edit and trash to the comment owner (%s)", async (mode) => { const seeded = await seedQueue(); const initial = await request(app(seeded.companyId, "other-operator")) .get(`/api/issues/${seeded.issueId}/queued-comments`); @@ -1637,7 +1644,9 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { .send({ queueId: seeded.wakeId, revision: initial.body.revision, body: "not mine" }); expect(edit.status).toBe(403); const discard = await request(app(seeded.companyId, "other-operator")) - .delete(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) + .delete(mode === "cancel" + ? `/api/issues/${seeded.issueId}/comments/${seeded.commentIds[0]}?mode=cancel` + : `/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) .send({ queueId: seeded.wakeId, revision: initial.body.revision }); expect(discard.status).toBe(403); }); @@ -1868,7 +1877,7 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { expect(steerNativeSessionMock.mock.calls).toHaveLength(steeringCalls); }); - it("serializes discard against queued-run claim", async () => { + it.each(["discard", "cancel"] as const)("serializes discard against queued-run claim (%s)", async (mode) => { const seeded = await seedQueue(); await db.delete(issueComments).where(eq(issueComments.id, seeded.commentIds[1])); await db @@ -1906,7 +1915,9 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { const [discard] = await Promise.all([ request(app(seeded.companyId)) - .delete(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) + .delete(mode === "cancel" + ? `/api/issues/${seeded.issueId}/comments/${seeded.commentIds[0]}?mode=cancel` + : `/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) .send({ queueId: seeded.wakeId, revision: initial.body.revision }), heartbeat.resumeQueuedRuns(), ]); diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx index f8faac83c84..a1a03831c32 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx @@ -5,6 +5,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IssueQueuedCommentQueue } from "@paperclipai/shared"; +import { mergePendingIssueQueuedComments } from "@/lib/issue-queued-comment-queue"; import { ThemeProvider } from "@/context/ThemeContext"; import { MemoryRouter } from "@/lib/router"; import { @@ -387,8 +388,9 @@ describe("TaskChatQueuedMessages", () => { expect(container.textContent).toContain("Queued message discarded."); }); - it("keeps a too-late discard visible with an explicit error", async () => { + it.each(["wake-1", null])("keeps a too-late discard visible with an explicit error (queue=%s)", async (queueId) => { render({ + queue: { ...queue, queueId }, onDiscard: vi.fn().mockRejectedValue({ body: { details: { code: "queued_comment_already_dispatching" } }, }), @@ -429,13 +431,43 @@ describe("TaskChatQueuedMessages", () => { expect(container.textContent).not.toContain("Queued message discarded."); }); - it("does not expose queue controls before a real queue id is acknowledged", () => { - const props = render({ queue: { ...queue, queueId: null, state: null } }); + it("can discard a saved message while queue acknowledgement is pending", async () => { + const merged = mergePendingIssueQueuedComments({ + issueId: queue.issueId, + authoritativeQueue: queue, + pendingComments: [{ + comment: { ...queue.entries[0].comment, id: "optimistic-next" }, + targetRunId: queue.targetRunId, + }], + fallbackProtocol: queue.protocol, + })!; + expect(merged.queueId).toBeNull(); + const props = render({ queue: merged }); + const discard = container.querySelector( + '[data-testid="task-chat-queued-discard-comment-1"]', + ); + expect(discard?.disabled).toBe(false); + expect(container.querySelector( + '[data-testid="task-chat-queued-steer-comment-1"]', + )?.disabled).toBe(true); + await act(async () => { discard!.click(); }); + expect(props.onDiscard).toHaveBeenCalledWith("comment-1", "rev-1"); + expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).toBeNull(); + }); + + it("keeps another author's saved message protected while acknowledgement is pending", () => { + const props = render({ + queue: { + ...queue, + queueId: null, + entries: [{ ...queue.entries[0], canDiscard: false }], + }, + }); const discard = container.querySelector( '[data-testid="task-chat-queued-discard-comment-1"]', ); expect(discard?.disabled).toBe(true); - discard?.click(); + discard!.click(); expect(props.onDiscard).not.toHaveBeenCalled(); }); diff --git a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx index 442f763f6c7..8772e7e274e 100644 --- a/ui/src/components/task-chat/TaskChatQueuedMessages.tsx +++ b/ui/src/components/task-chat/TaskChatQueuedMessages.tsx @@ -250,11 +250,7 @@ function SortableQueuedMessage({