mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 20:09:05 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task composer keeps unsent text across page reloads. > - A server receipt confirms a submitted message after a reload. > - Replayed cleanup can apply the old text offset to a newer draft twice. > - This pull request reconciles each confirmed attempt once and preserves both tabs’ unsent intent. > - The full newer draft stays available for the next send. ## Linked Issues or Issue Description **What happened?** Reloading the classic composer while a save was pending could truncate a newer draft. Replayed receipt cleanup changed `A newer draft written while delivery was pending.` into `nding.`. The storage helper rejected the duplicate settlement, but the effect still changed the editor and its body reference. **Expected behavior** A receipt settles its retained submission once. Later cleanup must preserve the newer draft in both the editor and browser storage. **Steps to reproduce** 1. Send a comment and hold its HTTP response after the server accepts it. 2. Type a newer draft, then reload the page. 3. Restore the matching receipt under React StrictMode. 4. Inspect the newer draft after effect replay and unmount. The deterministic regression reproduces this on current master. The existing browser test exposed the issue during #14329 verification. Related draft persistence code came from #13338. A search found no separate fix for this duplicate settlement. ## What Changed - Reconcile each confirmed attempt once in memory so duplicate effects cannot trim its newer draft again. - Unlock confirmed drafts when storage writes fail. Never acquire another tab’s pending receipt or assume its text is newer. Keep a conflicting local draft and its attachments in tab-scoped session storage while preserving the shared draft unchanged. - Add component regressions for StrictMode replay, exact editor and stored bytes, foreign receipts, attachment-only differences, reload recovery, unavailable storage, and task navigation before autosave. A brief notice explains when this tab has a separate draft. ## Verification - RED: the new test received `nding.` instead of the full newer draft before the fix. - Review RED: three cases reproduced a locked composer after failed storage writes or another tab's settlement. A further negative case preserves a newer retained attempt and its attachments. - Cross-tab review RED: four deterministic cases reproduced lost local text, foreign receipt takeover, attachment loss when text matched, and overwriting newer stored text. - GREEN: 118 tests across `IssueChatThread`, `composer-draft`, and `comment-submit-draft`, including same-mounted A → B → A navigation and a full recovery-storage failure. Two further lifecycle RED tests verify finishing a recovery returns to the shared draft on a later visit while continued typing and attachments retain recovery. - Both existing browser reload cases passed against a fresh server and database on exact head `ceb80aca77fc8cc0f813c328ba87025b3e1a2222` (42.5 seconds), with classic mode enabled and disabled. The browser flow verifies one original comment, a preserved draft, and a successful second send. - UI typecheck, token gates, and the shipped static UI build passed. The initial typecheck required the fresh worktree's plugin SDK build; the retry passed after that dependency built. - The session-only failure mock also preserves localStorage on platforms where both share the Storage prototype; this fixes the Linux workspace test failure. - All 56 checks passed on `4c30dcccc4fe5b90dcd08dd1d90475ea89e4a376`; Greptile is 5/5 with zero open review threads. An unrelated runner baseline scan hit its existing 100 ms deadline once; its isolated test and the single failed CI job passed on retry without source changes. This four-file UI fix does not change server or database code. - Final alternate-staging acceptance passed on deployed source `d884e1ab046cc76004e35e6091e9e6e2c918c9eb`, with exact health checked before and after. Three real browser cases used shipped static UI and real API saves: reload during an accepted-but-unacknowledged save in both composer variants, plus two classic tabs with different drafts. The test deliberately held only its own accepted POST acknowledgement and temporarily withheld its own GET receipt from one tab to reproduce settlement ordering. Both drafts survived independent reloads, the shared draft remained intact after the other tab sent, and finishing recovery returned to the shared draft on a later visit. Exact request IDs/counts and server comment bytes passed; no provider was invoked. Screenshots preserve the live drafts before fixture cleanup. - An ordinary authenticated Chrome/CUA visit independently showed the exact original and newer comments; an additional unsent draft survived reload and was then cleared. All three isolated fixtures are complete, browser contexts are closed, and original user tasks were untouched. ## Risks - Reconciliation uses the exact request ID and draft key. Conflicting drafts stay separate; the tab recovery survives same-tab reload through session storage and does not outlive the browser tab. If recovery storage is unavailable, the editor keeps its in-memory text and explicitly warns the user to copy it before leaving. - Attachment selections remain bounded to 20 per draft; if combining equal-text snapshots would exceed that limit, each original selection stays in its respective draft. - No API, schema, or migration changes. The status notice uses existing design tokens. ## Model Used OpenAI Codex, GPT-6, with tool use and code execution. The runtime does not expose the exact deployment variant or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
159 lines
6.8 KiB
TypeScript
159 lines
6.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
clearDraft,
|
|
loadDraft,
|
|
loadDraftRecoveryKey,
|
|
preserveDraftInTab,
|
|
loadDraftAttachments,
|
|
saveDraft,
|
|
saveDraftAttachments,
|
|
loadDraftSubmission,
|
|
saveDraftSubmission,
|
|
clearDraftSubmission,
|
|
settleDraftSubmission,
|
|
} from "./composer-draft";
|
|
|
|
describe("task draft upload receipts", () => {
|
|
const key = "paperclip:issue-comment-draft:task-one";
|
|
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
|
const receipt = {
|
|
attachmentId: id,
|
|
name: "file.txt",
|
|
size: 5,
|
|
inline: false,
|
|
contentPath: `/api/attachments/${id}/content`,
|
|
};
|
|
beforeEach(() => localStorage.clear());
|
|
it("persists a tab recovery without touching shared text, receipts, or pending submission", () => {
|
|
sessionStorage.clear();
|
|
saveDraft(key, "Other tab text");
|
|
saveDraftAttachments(key, [receipt]);
|
|
saveDraftSubmission(key, { attemptId: id, reviewed: false });
|
|
const fork = preserveDraftInTab(key, "This tab text", [receipt]);
|
|
expect(fork.persisted).toBe(true);
|
|
expect(loadDraftRecoveryKey(key)).toBe(fork.key);
|
|
expect(loadDraft(fork.key)).toBe("This tab text");
|
|
expect(loadDraftAttachments(fork.key)).toEqual([receipt]);
|
|
expect(loadDraftSubmission(fork.key)).toBeNull();
|
|
expect(loadDraft(key)).toBe("Other tab text");
|
|
expect(loadDraftAttachments(key)).toEqual([receipt]);
|
|
expect(loadDraftSubmission(key)?.attemptId).toBe(id);
|
|
clearDraft(fork.key);
|
|
expect(loadDraft(fork.key)).toBe("");
|
|
expect(loadDraft(key)).toBe("Other tab text");
|
|
expect(loadDraftSubmission(key)?.attemptId).toBe(id);
|
|
});
|
|
|
|
it("retires a finished recovery but keeps or restores its mapping for unsent work", () => {
|
|
sessionStorage.clear();
|
|
saveDraft(key, "Shared draft stays available");
|
|
const fork = preserveDraftInTab(key, "Recovered message", []);
|
|
saveDraftSubmission(fork.key, { attemptId: id, reviewed: false });
|
|
expect(settleDraftSubmission(fork.key, id)).toBe(true);
|
|
expect(loadDraftRecoveryKey(key)).toBeNull();
|
|
expect(loadDraft(key)).toBe("Shared draft stays available");
|
|
|
|
saveDraft(fork.key, "Next recovered draft");
|
|
expect(loadDraftRecoveryKey(key)).toBe(fork.key);
|
|
saveDraftSubmission(fork.key, { attemptId: id, reviewed: false, nextDraftOffset: 0, submittedAttachmentIds: [] });
|
|
expect(settleDraftSubmission(fork.key, id)).toBe(true);
|
|
expect(loadDraftRecoveryKey(key)).toBe(fork.key);
|
|
expect(loadDraft(fork.key)).toBe("Next recovered draft");
|
|
clearDraft(fork.key);
|
|
expect(loadDraftRecoveryKey(key)).toBeNull();
|
|
saveDraftAttachments(fork.key, [receipt]);
|
|
expect(loadDraftRecoveryKey(key)).toBe(fork.key);
|
|
expect(loadDraftAttachments(fork.key)).toEqual([receipt]);
|
|
});
|
|
|
|
it("settles only submitted text and attachments while preserving the next draft", () => {
|
|
const nextId = "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
|
const nextReceipt = { ...receipt, attachmentId: nextId, contentPath: `/api/attachments/${nextId}/content` };
|
|
saveDraft(key, "Sent\n\nNext");
|
|
saveDraftAttachments(key, [receipt]);
|
|
saveDraftSubmission(key, { attemptId: id, reviewed: false, nextDraftOffset: 6, submittedAttachmentIds: [id] });
|
|
saveDraftAttachments(key, [receipt, nextReceipt], nextId);
|
|
expect(loadDraftAttachments(key)).toEqual([receipt]);
|
|
saveDraftAttachments(key, [receipt, nextReceipt], id);
|
|
expect(settleDraftSubmission(key, nextId)).toBe(false);
|
|
expect(settleDraftSubmission(key, id)).toBe(true);
|
|
expect(loadDraft(key)).toBe("Next");
|
|
expect(loadDraftAttachments(key)).toEqual([nextReceipt]);
|
|
expect(loadDraftSubmission(key)).toBeNull();
|
|
});
|
|
it("keeps chat drafts and pending submission fences within the current tab", () => {
|
|
sessionStorage.clear();
|
|
const chatKey = "paperclip:agent-chat-draft:company:user:agent";
|
|
saveDraft(chatKey, "My chat draft");
|
|
saveDraftSubmission(chatKey, { attemptId: id, reviewed: false });
|
|
expect(loadDraft(chatKey)).toBe("My chat draft");
|
|
expect(loadDraftSubmission(chatKey)?.attemptId).toBe(id);
|
|
expect(localStorage.getItem(chatKey)).toBeNull();
|
|
expect(localStorage.getItem(`${chatKey}:submission:v1`)).toBeNull();
|
|
sessionStorage.clear();
|
|
expect(loadDraftSubmission(chatKey)).toBeNull();
|
|
});
|
|
it("retains a closed task-specific uncertainty marker and only settles the same attempt", () => {
|
|
saveDraftSubmission(key, { attemptId: id, reviewed: false });
|
|
expect(loadDraftSubmission(key)).toEqual({
|
|
attemptId: id,
|
|
reviewed: false,
|
|
});
|
|
localStorage.setItem(
|
|
"other:submission:v1",
|
|
localStorage.getItem(`${key}:submission:v1`)!,
|
|
);
|
|
expect(loadDraftSubmission("other")).toBeNull();
|
|
clearDraftSubmission(key, "different-attempt");
|
|
expect(loadDraftSubmission(key)?.attemptId).toBe(id);
|
|
clearDraftSubmission(key, id);
|
|
expect(loadDraftSubmission(key)).toBeNull();
|
|
saveDraftSubmission(key, { attemptId: id, reviewed: true });
|
|
clearDraft(key);
|
|
expect(loadDraftSubmission(key)?.attemptId).toBe(id);
|
|
clearDraft(key, id);
|
|
expect(loadDraftSubmission(key)).toBeNull();
|
|
});
|
|
it("retains legacy plain text and restores only closed task-keyed receipt metadata", () => {
|
|
saveDraft(key, "Existing draft");
|
|
saveDraftAttachments(key, [receipt]);
|
|
expect(localStorage.getItem(key)).toBe("Existing draft");
|
|
expect(loadDraft(key)).toBe("Existing draft");
|
|
expect(loadDraftAttachments(key)).toEqual([receipt]);
|
|
expect(loadDraftAttachments("different-task")).toEqual([]);
|
|
localStorage.setItem(
|
|
"different-task:attachments:v1",
|
|
localStorage.getItem(`${key}:attachments:v1`)!,
|
|
);
|
|
expect(loadDraftAttachments("different-task")).toEqual([]);
|
|
clearDraft(key);
|
|
expect(loadDraftAttachments(key)).toEqual([]);
|
|
});
|
|
it.each([
|
|
{ ...receipt, contentPath: "https://example.test/private" },
|
|
{ ...receipt, contentPath: `/api/attachments/other/content` },
|
|
{ ...receipt, attachmentId: "not-an-id" },
|
|
{ ...receipt, size: -1 },
|
|
{ ...receipt, inline: "true" },
|
|
{ name: "pending.txt", status: "uploading" },
|
|
{ name: "failed.txt", status: "error" },
|
|
{ ...receipt, status: "uploading" },
|
|
{ ...receipt, status: "error" },
|
|
])("does not restore an invalid or unusable receipt %#", (invalid) => {
|
|
saveDraftAttachments(key, [invalid]);
|
|
expect(loadDraftAttachments(key)).toEqual([]);
|
|
});
|
|
it("bounds count and raw record size without interpreting Markdown", () => {
|
|
saveDraftAttachments(
|
|
key,
|
|
Array.from({ length: 21 }, () => receipt),
|
|
);
|
|
expect(loadDraftAttachments(key)).toEqual([]);
|
|
localStorage.setItem(`${key}:attachments:v1`, "x".repeat(32_769));
|
|
expect(loadDraftAttachments(key)).toEqual([]);
|
|
saveDraft(key, `[unbound](${receipt.contentPath})`);
|
|
expect(loadDraftAttachments(key)).toEqual([]);
|
|
});
|
|
});
|