Files
hapi/web/src/lib/composer-attachment-drafts.test.ts
T
bdf8da1d5a fix(web): preserve drafts across inactive session resume (#1378)
* fix(web): preserve drafts across inactive session resume

* fix(web): address resumed draft review findings

* fix(web): preserve drafts during hydration resume

* fix(web): reset resume cache when session lifecycle changes

Clear resolvedSessionRef when session id or active flag changes so a same-id resume that later archives again cannot skip a required new resume.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): gate inactive attachments on resumability

Skip the attachment adapter and disable drops when an inactive session cannot resume, so file picks do not become stuck error attachments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): avoid source-session 404 during reopen handoff

When reopen merges into a different id, skip invalidating the source session detail so draft transfer can finish before navigation without flashing Session unavailable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): include in-flight attachments in inactive resume handoff

Pass the selected file into draft transfer and coalesce concurrent multi-file drops so resume navigation cannot drop attachments that were never published to the live snapshot.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): harden inactive draft handoff edge cases

Re-check attachment cancellation after resume, always copy from the source draft instead of a stale target snapshot, and force a fresh getSession after optimistic seeding.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): prevent passive resume and preserve staggered handoffs

Restore attachments only on active sessions so draft hydration cannot resume an archived session, and remember completed handoff targets so slower multi-file adds append instead of overwriting.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): avoid empty inactive snapshots shadowing attachment drafts

When attachment restoration is disabled, persist composer text without publishing an empty live snapshot so reopen can still transfer IndexedDB attachments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): cover inactive draft archive-switch-reopen lifecycle

Add a component-level harness that exercises HappyComposer snapshot rules with useComposerDraft and transferComposerDraft across archive, session switch, and reopen to a new id.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): resolve SessionChat conflict markers for draft handoff

Keep upstream send-message types and the inactive-resume handoffComposerDraft import so typecheck no longer dies on committed merge markers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger CI after conflict-marker fix

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retry CI after Actions outage (Meta daily)

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger CI and PR review on current tip

Re-queue Test and Codex PR Review after the conflict-marker fix and Actions outage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): do not overwrite stored attachments from inactive composer

Skip attachment draft persistence on unmount when restoration is disabled, so a failed inactive file pick cannot replace hidden IndexedDB attachments with a partial visible list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): merge inactive picks into stored attachment drafts

When restoration is disabled, persist visible pending attachments by merging them with IndexedDB instead of clearing the live snapshot alone, so a failed resume still keeps newly selected files for the next reopen.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): harden inactive draft persist for failed picks

Keep pathless attachment ids stable, serialize inactive merge/writes, drop removed visible picks from IndexedDB, and await pending persist before transfer/reopen.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): navigate after cancel during inactive resume handoff

If resume already merged into a new session id, still call onSessionResolved without the cancelled file so draft transfer + navigation leave the deleted source route.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): harden inactive draft handoff cancel and persist

Carry a live isCancelled predicate through resume handoff so mid-transfer
removes drop the file from the source snapshot, persist inactive blobs only
on attachment revision (not keystrokes), and keep upload metadata on
same-target staggered appends.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): await durable draft move and tombstone source

Cross-session transfer now awaits an atomic IndexedDB put+delete via
moveDraftAttachments, clears the source text draft, and skips unmount
re-persist once the source id is marked handed off.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): re-sample cancel after draft drain; fail closed on move

moveDraftAttachments resolves attachments only after awaiting queued
writes, and rolls cache back + throws when a real IndexedDB transaction
fails so transfer cannot clear the source or mark a handoff on loss.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): re-sample composer text after awaited draft move

Keystrokes during the IndexedDB drain keep updating the source draft;
re-read live/persisted text after the move resolves before clearing the
source and writing the target snapshot.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): navigate after resume even if draft move fails

Hub reopen can delete the source row before local IndexedDB finishes.
Catch transfer failures, still copy text best-effort, and always navigate
via transferComposerDraftThenNavigate / handoff onNavigable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): harden draft move prune and in-flight persist

Delete the source row before put/prune so a full retention store cannot
evict an unrelated draft, and buffer inactive persist edits during an
in-flight transfer onto the target instead of recreating the source.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): close inactive draft remount and transfer races

Reset visibility bookkeeping on inactive remount, install the transfer
barrier before the first await, and await a durable corrective target
write when late edits land during an in-flight move.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): unbreak draft transfer typecheck and remaining races

Re-read pendingTransfers.latest after awaits (TS2339), propagate real
IndexedDB write failures on corrective same-target moves, and restore only
missing stored attachment ids when same-id resume already has a visible pick.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): stabilize late draft edits and multi-file resume

Repeat corrective target writes until pending.latest is unchanged across
the await, sync keystrokes into the transfer barrier, and keep one resume
promise per inactive adapter instance so staggered files join the same
handoff after navigation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep IDB attachments when typing during transfer

Track inactive keystrokes on pending.latestText instead of inventing
pending.latest with an empty attachment list that would overwrite
hidden stored files during a cross-session move.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): fail closed when draft attachment reads fail

Cross-session transfer now requests throwOnError for IndexedDB reads so a
transient empty result cannot delete the source draft during the durable move.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): fail closed on inactive merge reads and keep target text

Strict-read every inactive attachment RMW merge so a transient IDB error
cannot publish a partial live snapshot, and copy composer text to the
resumed id before aborting when attachment transfer reads fail.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep resumed session active after stale detail fetch

Reapply optimistic active after background getSession settles so a lagging
inactive REST snapshot cannot flip the target composer back, and catch
strict inactive persist rejections at fire-and-forget call sites.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): prune attachments cancelled during durable draft move

Always rewrite the target after the cross-session move so isCancelled
flips during the IndexedDB commit window still drop the file, and prune
again in handoff before navigation when the batch reports cancellation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): stabilize multi-file cancel across draft corrections

Treat the pending cancellation set as part of post-move stability so a
sibling remove mid-corrective rewrite is re-sampled before navigation,
and loop handoff same-target rewrites until that set stops changing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): transfer inactive drafts before text-only resume send

Await onSessionResolved so composer draft handoff completes before the
send mutation, and route text-only auto-resume through
transferComposerDraftThenNavigate like upload/reopen paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): transfer Files reopen drafts and respect SSE active

Wire the Files-page reopen path through transferComposerDraftThenNavigate,
and refresh resumed session detail only when the cached active flag still
matches so a mid-flight inactive SSE transition is not resurrected.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): defer send until hidden inactive drafts hydrate

After text-only resume transfers stored attachments, skip the mutation
when the target draft still has files the inactive composer never
exposed, so clearDraftsAfterSend cannot wipe them after a text-only POST.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): defer any attachment resume and expose stored drafts

Always defer cross-session send when visible or stored attachments exist
so source-scoped upload paths are not POSTed, and surface IndexedDB
attachment presence to disable scheduling on inactive composers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): resume same-id drafts and block pending schedule races

Return resumed independently of session id so same-row PTY/Pi resumes
still defer for hidden files, route attachment-only Send through an
explicit resume callback, and treat incomplete inactive hydration as
schedule-blocking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve send text during deferred draft handoff

Pass the immutable submitted text into draft transfer so assistant-ui clearing the composer while resume is in flight cannot drop text from deferred hidden-attachment sends.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: HeavyGee <133152184+heavygee@users.noreply.github.com>
2026-08-08 13:47:25 +08:00

439 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('composer-attachment-drafts', () => {
beforeEach(() => {
vi.stubGlobal('indexedDB', undefined)
vi.resetModules()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('keeps files available in memory when IndexedDB is unavailable', async () => {
const mod = await import('./composer-attachment-drafts')
const file = new File(['image bytes'], 'pasted.png', {
type: 'image/png',
lastModified: 123,
})
mod.saveDraftAttachments('session-1', [{ id: 'attachment-1', file }])
const restored = await mod.getDraftAttachments('session-1')
expect(restored).toHaveLength(1)
expect(restored[0]).not.toBe(file)
expect(restored[0]?.name).toBe('pasted.png')
expect(restored[0]?.type).toBe('image/png')
expect(restored[0]?.lastModified).toBe(123)
expect(restored[0]?.size).toBe(file.size)
})
it('isolates attachment drafts by session', async () => {
const mod = await import('./composer-attachment-drafts')
mod.saveDraftAttachments('session-a', [{ id: 'a', file: new File(['a'], 'a.txt') }])
mod.saveDraftAttachments('session-b', [{ id: 'b', file: new File(['b'], 'b.txt') }])
expect((await mod.getDraftAttachments('session-a'))[0]?.name).toBe('a.txt')
expect((await mod.getDraftAttachments('session-b'))[0]?.name).toBe('b.txt')
})
it('clears cached attachment drafts', async () => {
const mod = await import('./composer-attachment-drafts')
mod.saveDraftAttachments('session-1', [{ id: 'x', file: new File(['x'], 'x.txt') }])
mod.clearDraftAttachments('session-1')
expect(await mod.getDraftAttachments('session-1')).toEqual([])
})
it('does not read stale IndexedDB data while a clear is being persisted', async () => {
const mod = await import('./composer-attachment-drafts')
mod.saveDraftAttachments('session-1', [{ id: 'x', file: new File(['x'], 'x.txt') }])
mod.clearDraftAttachments('session-1')
await new Promise((resolve) => setTimeout(resolve, 0))
const open = vi.fn(() => {
throw new Error('cleared drafts must be served from the cache tombstone')
})
vi.stubGlobal('indexedDB', { open })
expect(await mod.getDraftAttachments('session-1')).toEqual([])
expect(open).not.toHaveBeenCalled()
})
it('retains completed upload metadata on restored files', async () => {
const mod = await import('./composer-attachment-drafts')
const file = new File(['image'], 'ready.png', { type: 'image/png' })
mod.saveDraftAttachments('session-1', [{
id: 'attachment-ready',
file,
path: '/uploads/ready.png',
previewUrl: 'data:image/png;base64,aW1hZ2U=',
}])
const [restored] = await mod.getDraftAttachments('session-1')
expect(restored && mod.getRestoredUploadMetadata(restored)).toEqual({
id: 'attachment-ready',
path: '/uploads/ready.png',
previewUrl: 'data:image/png;base64,aW1hZ2U=',
})
})
it('retains stable ids for pathless pending files across persist passes', async () => {
const mod = await import('./composer-attachment-drafts')
const file = new File(['pending'], 'pending.txt')
mod.saveDraftAttachments('session-1', [{ id: 'pending-1', file }])
const [first] = await mod.getDraftAttachments('session-1')
expect(first && mod.getRestoredUploadMetadata(first)?.id).toBe('pending-1')
mod.saveDraftAttachments('session-1', [{
id: mod.getRestoredUploadMetadata(first!)!.id,
file: first!,
}])
const secondPass = await mod.getDraftAttachments('session-1')
expect(secondPass).toHaveLength(1)
expect(secondPass[0] && mod.getRestoredUploadMetadata(secondPass[0])?.id).toBe('pending-1')
})
it('moves attachments to the target and tombstones the source in cache', async () => {
const mod = await import('./composer-attachment-drafts')
const file = new File(['payload'], 'notes.txt')
mod.saveDraftAttachments('session-source', [{ id: 'a1', file }])
await mod.moveDraftAttachments('session-source', 'session-target', () => [{ id: 'a1', file }])
expect((await mod.getDraftAttachments('session-target')).map((item) => item.name)).toEqual(['notes.txt'])
expect(await mod.getDraftAttachments('session-source')).toEqual([])
})
it('can surface IndexedDB read failures when throwOnError is set', async () => {
vi.stubGlobal('indexedDB', {
open: () => {
const request: {
result: unknown
onsuccess: ((ev: unknown) => void) | null
onerror: ((ev: unknown) => void) | null
onupgradeneeded: ((ev: unknown) => void) | null
error: Error
} = {
result: undefined,
onsuccess: null,
onerror: null,
onupgradeneeded: null,
error: new Error('open failed'),
}
queueMicrotask(() => request.onerror?.({}))
return request
},
})
vi.resetModules()
const mod = await import('./composer-attachment-drafts')
await expect(mod.getDraftAttachments('session-1')).resolves.toEqual([])
await expect(mod.getDraftAttachments('session-1', { throwOnError: true })).rejects.toThrow()
})
it('commits a put+delete move when IndexedDB is available', async () => {
const store = new Map<string, unknown>()
const fakeDb = {
transaction(_name: string, _mode: string) {
let pendingCallbacks = 0
const finishLater = (cb: () => void) => {
pendingCallbacks += 1
queueMicrotask(() => {
cb()
pendingCallbacks -= 1
if (pendingCallbacks === 0) {
transaction.oncomplete?.({})
}
})
}
const objectStore = () => ({
put: (record: { sessionId: string }) => {
store.set(record.sessionId, record)
},
delete: (sessionId: string) => {
store.delete(sessionId)
},
getAll: () => {
const request: { onsuccess: ((ev: unknown) => void) | null; result?: unknown[] } = {
onsuccess: null,
}
finishLater(() => {
request.result = [...store.values()]
request.onsuccess?.({})
})
return request
},
})
const transaction = {
objectStore,
oncomplete: null as ((ev: unknown) => void) | null,
onerror: null as ((ev: unknown) => void) | null,
onabort: null as ((ev: unknown) => void) | null,
}
// If no async requests were scheduled, complete on next tick.
queueMicrotask(() => {
if (pendingCallbacks === 0) transaction.oncomplete?.({})
})
return transaction
},
close() {},
}
vi.stubGlobal('indexedDB', {
open: () => {
const request: {
result: typeof fakeDb
onsuccess: ((ev: unknown) => void) | null
onerror: ((ev: unknown) => void) | null
onupgradeneeded: ((ev: unknown) => void) | null
} = {
result: fakeDb,
onsuccess: null,
onerror: null,
onupgradeneeded: null,
}
queueMicrotask(() => request.onsuccess?.({}))
return request
},
})
vi.resetModules()
const mod = await import('./composer-attachment-drafts')
const file = new File(['payload'], 'notes.txt')
mod.saveDraftAttachments('session-source', [{ id: 'a1', file }])
await new Promise((resolve) => setTimeout(resolve, 0))
await mod.moveDraftAttachments('session-source', 'session-target', () => [{ id: 'a1', file }])
expect(store.has('session-target')).toBe(true)
expect(store.has('session-source')).toBe(false)
expect((await mod.getDraftAttachments('session-target')).map((item) => item.name)).toEqual(['notes.txt'])
expect(await mod.getDraftAttachments('session-source')).toEqual([])
})
it('does not prune an unrelated draft when moving at the retention cap', async () => {
const store = new Map<string, { sessionId: string; updatedAt: number; files: unknown[] }>()
const fakeDb = {
transaction(_name: string, _mode: string) {
let pendingCallbacks = 0
const finishLater = (cb: () => void) => {
pendingCallbacks += 1
queueMicrotask(() => {
cb()
pendingCallbacks -= 1
if (pendingCallbacks === 0) transaction.oncomplete?.({})
})
}
const objectStore = () => ({
put: (record: { sessionId: string; updatedAt: number; files: unknown[] }) => {
store.set(record.sessionId, record)
},
delete: (sessionId: string) => {
store.delete(sessionId)
},
getAll: () => {
const request: { onsuccess: ((ev: unknown) => void) | null; result?: unknown[] } = {
onsuccess: null,
}
finishLater(() => {
request.result = [...store.values()]
request.onsuccess?.({})
})
return request
},
})
const transaction = {
objectStore,
oncomplete: null as ((ev: unknown) => void) | null,
onerror: null as ((ev: unknown) => void) | null,
onabort: null as ((ev: unknown) => void) | null,
}
queueMicrotask(() => {
if (pendingCallbacks === 0) transaction.oncomplete?.({})
})
return transaction
},
close() {},
}
vi.stubGlobal('indexedDB', {
open: () => {
const request: {
result: typeof fakeDb
onsuccess: ((ev: unknown) => void) | null
onerror: ((ev: unknown) => void) | null
onupgradeneeded: ((ev: unknown) => void) | null
} = {
result: fakeDb,
onsuccess: null,
onerror: null,
onupgradeneeded: null,
}
queueMicrotask(() => request.onsuccess?.({}))
return request
},
})
vi.resetModules()
const mod = await import('./composer-attachment-drafts')
// Fill the retention cap with 49 unrelated drafts + the source (50 total).
// Source is mid-range by updatedAt so it is not the oldest entry.
for (let i = 0; i < 49; i++) {
store.set(`keep-${i}`, {
sessionId: `keep-${i}`,
updatedAt: i + 1,
files: [],
})
}
const file = new File(['payload'], 'notes.txt')
mod.saveDraftAttachments('session-source', [{ id: 'a1', file }])
store.set('session-source', {
sessionId: 'session-source',
updatedAt: 25,
files: [{ id: 'a1', name: 'notes.txt', type: '', lastModified: 0, blob: file }],
})
expect(store.size).toBe(50)
await new Promise((resolve) => setTimeout(resolve, 0))
await mod.moveDraftAttachments('session-source', 'session-target', () => [{ id: 'a1', file }])
expect(store.has('session-target')).toBe(true)
expect(store.has('session-source')).toBe(false)
expect(store.has('keep-0')).toBe(true)
expect(store.size).toBe(50)
})
it('rolls cache back and throws when the IndexedDB move transaction fails', async () => {
vi.stubGlobal('indexedDB', {
open: () => {
const request: {
result: {
transaction: () => {
objectStore: () => {
put: () => void
delete: () => void
getAll: () => { onsuccess: null }
}
oncomplete: null
onerror: ((ev: unknown) => void) | null
onabort: null
error: Error
}
close: () => void
}
onsuccess: ((ev: unknown) => void) | null
onerror: ((ev: unknown) => void) | null
onupgradeneeded: ((ev: unknown) => void) | null
} = {
result: {
transaction: () => {
const transaction = {
objectStore: () => ({
put: () => {},
delete: () => {},
getAll: () => ({ onsuccess: null }),
}),
oncomplete: null,
onerror: null as ((ev: unknown) => void) | null,
onabort: null,
error: new Error('quota exceeded'),
}
queueMicrotask(() => transaction.onerror?.({}))
return transaction
},
close: () => {},
},
onsuccess: null,
onerror: null,
onupgradeneeded: null,
}
queueMicrotask(() => request.onsuccess?.({}))
return request
},
})
vi.resetModules()
const mod = await import('./composer-attachment-drafts')
const file = new File(['payload'], 'notes.txt')
mod.saveDraftAttachments('session-source', [{ id: 'a1', file }])
await expect(mod.moveDraftAttachments(
'session-source',
'session-target',
() => [{ id: 'a1', file }],
)).rejects.toThrow(/quota exceeded|Composer draft move failed/)
expect((await mod.getDraftAttachments('session-source')).map((item) => item.name)).toEqual(['notes.txt'])
expect(await mod.getDraftAttachments('session-target')).toEqual([])
})
it('propagates a rejected same-target queued write instead of treating it as durable', async () => {
let failNextWrite = false
vi.stubGlobal('indexedDB', {
open: () => {
const request: {
result: {
transaction: () => {
objectStore: () => {
put: (record: { sessionId: string }) => void
delete: (sessionId: string) => void
getAll: () => { onsuccess: null; result?: unknown[] }
}
oncomplete: ((ev: unknown) => void) | null
onerror: ((ev: unknown) => void) | null
onabort: null
error: Error | null
}
close: () => void
}
onsuccess: ((ev: unknown) => void) | null
onerror: ((ev: unknown) => void) | null
onupgradeneeded: ((ev: unknown) => void) | null
} = {
result: {
transaction: () => {
const transaction = {
objectStore: () => ({
put: () => {},
delete: () => {},
getAll: () => ({ onsuccess: null, result: [] as unknown[] }),
}),
oncomplete: null as ((ev: unknown) => void) | null,
onerror: null as ((ev: unknown) => void) | null,
onabort: null,
error: null as Error | null,
}
queueMicrotask(() => {
if (failNextWrite) {
transaction.error = new Error('quota exceeded')
transaction.onerror?.({})
return
}
transaction.oncomplete?.({})
})
return transaction
},
close: () => {},
},
onsuccess: null,
onerror: null,
onupgradeneeded: null,
}
queueMicrotask(() => request.onsuccess?.({}))
return request
},
})
vi.resetModules()
const mod = await import('./composer-attachment-drafts')
const file = new File(['payload'], 'notes.txt')
failNextWrite = true
await expect(mod.moveDraftAttachments(
'session-target',
'session-target',
() => [{ id: 'a1', file }],
)).rejects.toThrow(/quota exceeded|Composer draft/)
})
})