mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* feat(hub,shared): scratchlist v2.2 hub attachment storage foundation (#921) Hub stores scratchlist attachment bytes on filesystem; SQLite holds AttachmentMetadata[] JSON via session_scratchlist.attachments (v11→v12). Upstream ladder: v10→v11 text-only scratchlist table (#896), v11→v12 attachments column. Configurable limits via HAPI_SCRATCHLIST_* env vars. Upload, serve, and limits REST routes; delete entry cleans hub files. Web promote/rehydrate still TODO. Soup renumber branch follows. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): scratchlist v2.2 attachment UX (#921) Route scratchlist-mode composer submits with attachments to hub storage, show image thumbnails in the drawer, and rehydrate attachments on promote to composer or queue (hub fetch → CLI upload for send). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): scratchlist attach submit, float thumbs, copy tooltip (#921) Hub upload adapter now sets path on ready attachments so the composer send button unlocks in scratchlist mode; routing label matches attachments too. Entry thumbnails float left with text wrap; copy tooltip clarifies text-only. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): adapt scratchlist update tests to patch API (#921) update() now takes { text?, attachments? }; v12 CRUD tests still passed a string. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): harden scratchlist attachment ownership and orphan cleanup Resolve claimed hub paths against the current session before persist, count on-disk session bytes for upload caps, delete blobs dropped on entry update, and DELETE pending uploads when composer remove runs. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop accidental .cursor files from attachment PR Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): exit scratchlist mode before rehydrate; delete raced uploads Promote-to-composer flushes mode exit so attachments use the chat adapter. Cancel-during-upload deletes the hub blob once upload returns. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): exact UUID delete match; stage hub paths on chat send Reject partial attachment ids on disk delete, and restage scratchlist hub attachments through uploadFile when sending after leaving scratchlist mode. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): skip text-only PUT resolve; cleanup session attachment dirs Text-only edits keep existing attachment metadata after session-id transfer. Require full UUID on resolve. Delete scratchlist attachment files when a session is deleted. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): scratchlist attach route, PUT bytes, orphan deletes Park only hub-resident attachments; subtract removed blobs from the PUT session cap; delete attachment files only when no other entry still references them. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): canonicalize scratchlist attachment filenames Resolve stores the on-disk sanitized name (not claimed.filename) and hardens Content-Disposition against CR/LF/quote injection. Co-authored-by: Cursor <cursoragent@cursor.com> * test(hub): cover toxic filename canonicalize on resolve Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): serialize scratchlist uploads; drop hub blobs after chat stage Per-session upload lock keeps disk byte caps honest under concurrency. After a successful toggle-off chat send, delete the staged hub copies so they no longer count against the session attachment budget. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(shared,web): allow clearing scratchlist attachments; cleanup staged uploads PUT may send attachments:[] without a text change. Staging to chat rolls back partial normal-upload copies on failure. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): re-key scratchlist attachment files on session merge Move hub blobs when scratchlist rows transfer between session ids so quota and path ownership stay correct. Reject PUT that would leave an empty textless entry after clearing attachments. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): reuse restored scratchlist hub attachments without re-upload Composer draft remount was re-uploading blobs that already had a hapi-hub:scratchlist path, orphaning the originals against session quota. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
applyModelChangeWithReasoningRollback,
|
|
buildGoalStateMessages,
|
|
isScratchlistHotkeyBlockedTarget,
|
|
isScratchlistToggleHotkey,
|
|
resolvePiContextWindow,
|
|
shouldAutoClearPendingSchedule,
|
|
shouldRouteToScratchlist,
|
|
} from './SessionChat'
|
|
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
|
import type { AttachmentMetadata, DecryptedMessage } from '@/types/api'
|
|
|
|
describe('applyModelChangeWithReasoningRollback', () => {
|
|
it('restores the previous effort when the model switch fails after clearing it', async () => {
|
|
const modelError = new Error('model switch failed')
|
|
const setModel = vi.fn(async () => { throw modelError })
|
|
const setModelReasoningEffort = vi.fn(async () => {})
|
|
|
|
await expect(applyModelChangeWithReasoningRollback({
|
|
model: 'gpt-next',
|
|
previousModelReasoningEffort: 'extreme',
|
|
shouldClearReasoningEffort: true,
|
|
setModel,
|
|
setModelReasoningEffort
|
|
})).rejects.toBe(modelError)
|
|
|
|
expect(setModelReasoningEffort.mock.calls).toEqual([[null], ['extreme']])
|
|
expect(setModel).toHaveBeenCalledWith('gpt-next')
|
|
})
|
|
|
|
it('keeps the cleared effort when the model switch succeeds', async () => {
|
|
const setModel = vi.fn(async () => {})
|
|
const setModelReasoningEffort = vi.fn(async () => {})
|
|
|
|
await applyModelChangeWithReasoningRollback({
|
|
model: 'gpt-next',
|
|
previousModelReasoningEffort: 'extreme',
|
|
shouldClearReasoningEffort: true,
|
|
setModel,
|
|
setModelReasoningEffort
|
|
})
|
|
|
|
expect(setModelReasoningEffort).toHaveBeenCalledOnce()
|
|
expect(setModelReasoningEffort).toHaveBeenCalledWith(null)
|
|
expect(setModel).toHaveBeenCalledWith('gpt-next')
|
|
})
|
|
})
|
|
|
|
describe('resolvePiContextWindow', () => {
|
|
const models = [
|
|
{ provider: 'provider-a', modelId: 'shared-model', contextWindow: 100_000 },
|
|
{ provider: 'provider-b', modelId: 'shared-model', contextWindow: 200_000 },
|
|
]
|
|
|
|
it('uses the provider-qualified selected model when model ids collide', () => {
|
|
expect(resolvePiContextWindow(
|
|
models,
|
|
{ provider: 'provider-b', modelId: 'shared-model' },
|
|
'shared-model',
|
|
)).toBe(200_000)
|
|
})
|
|
|
|
it('falls back to the legacy model id when selected-model metadata is absent', () => {
|
|
expect(resolvePiContextWindow(models, undefined, 'shared-model')).toBe(100_000)
|
|
})
|
|
})
|
|
|
|
function userMessage(props: {
|
|
id: string
|
|
createdAt: number
|
|
localId?: string | null
|
|
invokedAt?: number | null
|
|
scheduledAt?: number | null
|
|
}): DecryptedMessage {
|
|
return {
|
|
id: props.id,
|
|
seq: null,
|
|
localId: props.localId ?? null,
|
|
content: {
|
|
role: 'user',
|
|
content: {
|
|
type: 'text',
|
|
text: 'hello'
|
|
}
|
|
},
|
|
createdAt: props.createdAt,
|
|
invokedAt: props.invokedAt,
|
|
scheduledAt: props.scheduledAt
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unit tests for shouldAutoClearPendingSchedule.
|
|
*
|
|
* The useEffect in SessionChat auto-clears only 'absolute' pending schedules
|
|
* when the chosen time expires. 'preset' schedules must NOT be auto-cleared
|
|
* because they are relative to send time and have no fixed expiry.
|
|
*
|
|
* This test guards against future refactors that accidentally break the
|
|
* preset-stays-alive invariant (a silent break: the effect would cancel the
|
|
* preset with no user-visible error before send time).
|
|
*/
|
|
describe('shouldAutoClearPendingSchedule', () => {
|
|
it('returns false for null (no schedule set)', () => {
|
|
expect(shouldAutoClearPendingSchedule(null)).toBe(false)
|
|
})
|
|
|
|
it('returns false for preset schedule — presets do not expire before send', () => {
|
|
const preset: PendingSchedule = { type: 'preset', preset: '+5m' }
|
|
expect(shouldAutoClearPendingSchedule(preset)).toBe(false)
|
|
})
|
|
|
|
it('returns false for all preset values', () => {
|
|
const presets: Array<'+5m' | '+30m' | '+1h' | '+4h'> = ['+5m', '+30m', '+1h', '+4h']
|
|
for (const p of presets) {
|
|
const pending: PendingSchedule = { type: 'preset', preset: p }
|
|
expect(shouldAutoClearPendingSchedule(pending)).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('returns true for absolute schedule — absolute schedules have a fixed expiry instant', () => {
|
|
const absolute: PendingSchedule = { type: 'absolute', ms: Date.now() + 60_000 }
|
|
expect(shouldAutoClearPendingSchedule(absolute)).toBe(true)
|
|
})
|
|
|
|
it('returns true for expired absolute schedule (ms in the past)', () => {
|
|
const expired: PendingSchedule = { type: 'absolute', ms: Date.now() - 1000 }
|
|
expect(shouldAutoClearPendingSchedule(expired)).toBe(true)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Unit tests for shouldRouteToScratchlist.
|
|
*
|
|
* Regression cover for upstream review on PR #798 / #1205: scratchlist-mode
|
|
* submissions must fall through to chat when the payload cannot be parked
|
|
* (schedule set, or any attachment still on a normal CLI upload path).
|
|
*/
|
|
describe('shouldRouteToScratchlist', () => {
|
|
function attachment(path = '/tmp/attach-1.png'): AttachmentMetadata {
|
|
return {
|
|
id: 'attach-1',
|
|
filename: 'attach-1.png',
|
|
mimeType: 'image/png',
|
|
size: 1024,
|
|
path,
|
|
}
|
|
}
|
|
|
|
function hubAttachment(): AttachmentMetadata {
|
|
return attachment('hapi-hub:scratchlist/default/session-1/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-a.png')
|
|
}
|
|
|
|
it('returns false when scratchlist mode is off, regardless of payload', () => {
|
|
expect(shouldRouteToScratchlist(false, undefined, null)).toBe(false)
|
|
expect(shouldRouteToScratchlist(false, [attachment()], null)).toBe(false)
|
|
expect(shouldRouteToScratchlist(false, undefined, Date.now() + 60_000)).toBe(false)
|
|
})
|
|
|
|
it('returns true when scratchlist mode is on and the payload is pure text', () => {
|
|
expect(shouldRouteToScratchlist(true, undefined, null)).toBe(true)
|
|
expect(shouldRouteToScratchlist(true, undefined, undefined)).toBe(true)
|
|
expect(shouldRouteToScratchlist(true, [], null)).toBe(true)
|
|
})
|
|
|
|
it('returns true when every attachment is already hub-resident', () => {
|
|
expect(shouldRouteToScratchlist(true, [hubAttachment()], null)).toBe(true)
|
|
expect(shouldRouteToScratchlist(true, [hubAttachment(), hubAttachment()], null)).toBe(true)
|
|
})
|
|
|
|
it('returns false when any attachment still has a normal CLI path', () => {
|
|
expect(shouldRouteToScratchlist(true, [attachment()], null)).toBe(false)
|
|
expect(shouldRouteToScratchlist(true, [hubAttachment(), attachment()], null)).toBe(false)
|
|
})
|
|
|
|
it('returns false when scratchlist mode is on but a scheduled-send is set', () => {
|
|
expect(shouldRouteToScratchlist(true, undefined, Date.now() + 60_000)).toBe(false)
|
|
expect(shouldRouteToScratchlist(true, [], 0)).toBe(false)
|
|
})
|
|
|
|
it('returns false when both attachments and scheduledAt are set', () => {
|
|
expect(shouldRouteToScratchlist(true, [hubAttachment()], Date.now() + 60_000)).toBe(false)
|
|
})
|
|
|
|
/**
|
|
* Bot follow-up on PR #798: handleSend gates pendingSchedule cleanup on
|
|
* routedToScratchlist, not scratchlistMode. So a scheduled chat send made
|
|
* while the scratchlist toggle is on (which falls through to chat per
|
|
* the previous tests) MUST also trigger schedule clear + scroll bump.
|
|
* This test pins the decision matrix that handleSend depends on.
|
|
*/
|
|
it('cleanup gate: scheduled chat send while scratchlist toggle is on still clears schedule', () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
// Scenario: mode on, no attachments, scheduled. shouldRouteToScratchlist
|
|
// must return false so handleSend's `if (!routedToScratchlist)` runs
|
|
// setPendingSchedule(null).
|
|
const routed = shouldRouteToScratchlist(true, undefined, scheduledAt)
|
|
expect(routed).toBe(false)
|
|
const shouldClearAfterAccepted = !routed
|
|
expect(shouldClearAfterAccepted).toBe(true)
|
|
})
|
|
|
|
it('cleanup gate: pure-text scratchlist add does NOT clear schedule', () => {
|
|
const routed = shouldRouteToScratchlist(true, undefined, null)
|
|
expect(routed).toBe(true)
|
|
const shouldClearAfterAccepted = !routed
|
|
expect(shouldClearAfterAccepted).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isScratchlistToggleHotkey', () => {
|
|
function k(over: Partial<{
|
|
metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; key: string
|
|
}>): { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; key: string } {
|
|
return { metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, key: '', ...over }
|
|
}
|
|
|
|
it('matches Ctrl+Shift+S (Linux/Windows)', () => {
|
|
expect(isScratchlistToggleHotkey(k({ ctrlKey: true, shiftKey: true, key: 'S' }))).toBe(true)
|
|
expect(isScratchlistToggleHotkey(k({ ctrlKey: true, shiftKey: true, key: 's' }))).toBe(true)
|
|
})
|
|
|
|
it('matches Cmd+Shift+S (macOS)', () => {
|
|
expect(isScratchlistToggleHotkey(k({ metaKey: true, shiftKey: true, key: 'S' }))).toBe(true)
|
|
})
|
|
|
|
it('rejects Cmd/Ctrl + S without shift (browser Save)', () => {
|
|
// Browsers reserve Ctrl-S / Cmd-S for "Save Page". The toggle MUST
|
|
// require shift so the user's save-page muscle memory keeps working.
|
|
expect(isScratchlistToggleHotkey(k({ ctrlKey: true, key: 's' }))).toBe(false)
|
|
expect(isScratchlistToggleHotkey(k({ metaKey: true, key: 's' }))).toBe(false)
|
|
})
|
|
|
|
it('rejects bare S / Shift+S (literal typing)', () => {
|
|
expect(isScratchlistToggleHotkey(k({ key: 's' }))).toBe(false)
|
|
expect(isScratchlistToggleHotkey(k({ shiftKey: true, key: 'S' }))).toBe(false)
|
|
})
|
|
|
|
it('rejects when Alt is also held (avoid clashes with OS shortcuts)', () => {
|
|
expect(isScratchlistToggleHotkey(k({
|
|
ctrlKey: true, shiftKey: true, altKey: true, key: 'S',
|
|
}))).toBe(false)
|
|
})
|
|
|
|
it('rejects unrelated keys', () => {
|
|
expect(isScratchlistToggleHotkey(k({ ctrlKey: true, shiftKey: true, key: 'A' }))).toBe(false)
|
|
expect(isScratchlistToggleHotkey(k({ ctrlKey: true, shiftKey: true, key: 'Tab' }))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isScratchlistHotkeyBlockedTarget', () => {
|
|
// Note: tests run under jsdom, so HTMLElement / HTMLInputElement etc.
|
|
// are real constructors that we can construct via document.createElement.
|
|
|
|
it('blocks hotkey when focus is in a single-line input', () => {
|
|
const input = document.createElement('input')
|
|
expect(isScratchlistHotkeyBlockedTarget(input)).toBe(true)
|
|
})
|
|
|
|
it('blocks hotkey when focus is in a select element', () => {
|
|
const select = document.createElement('select')
|
|
expect(isScratchlistHotkeyBlockedTarget(select)).toBe(true)
|
|
})
|
|
|
|
it('blocks hotkey when focus is on a contentEditable host', () => {
|
|
const div = document.createElement('div')
|
|
div.setAttribute('contenteditable', 'true')
|
|
expect(isScratchlistHotkeyBlockedTarget(div)).toBe(true)
|
|
})
|
|
|
|
it('blocks hotkey when focus is anywhere inside a [role=dialog]', () => {
|
|
const dialog = document.createElement('div')
|
|
dialog.setAttribute('role', 'dialog')
|
|
const inner = document.createElement('button')
|
|
dialog.appendChild(inner)
|
|
document.body.appendChild(dialog)
|
|
expect(isScratchlistHotkeyBlockedTarget(inner)).toBe(true)
|
|
document.body.removeChild(dialog)
|
|
})
|
|
|
|
it('does NOT block hotkey when focus is on the composer textarea', () => {
|
|
// The composer textarea is the EXPECTED focus target when the
|
|
// operator presses the shortcut. Blocking it would defeat the
|
|
// shortcut entirely.
|
|
const textarea = document.createElement('textarea')
|
|
expect(isScratchlistHotkeyBlockedTarget(textarea)).toBe(false)
|
|
})
|
|
|
|
it('does NOT block hotkey when focus is on a regular button', () => {
|
|
const button = document.createElement('button')
|
|
expect(isScratchlistHotkeyBlockedTarget(button)).toBe(false)
|
|
})
|
|
|
|
it('does NOT block hotkey when target is null (unfocused)', () => {
|
|
expect(isScratchlistHotkeyBlockedTarget(null)).toBe(false)
|
|
})
|
|
|
|
it('does NOT block hotkey when target is non-Element (e.g. window)', () => {
|
|
// Some keyboard events come with a non-Element target (e.g. window
|
|
// before focus settles). Should fall through.
|
|
expect(isScratchlistHotkeyBlockedTarget(window as unknown as EventTarget)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('buildGoalStateMessages', () => {
|
|
it('keeps immediate queued user messages so completed goal status can clear before timeline render', () => {
|
|
const now = 1_700_000_000_000
|
|
const messages = [
|
|
userMessage({
|
|
id: 'local-immediate',
|
|
localId: 'local-immediate',
|
|
createdAt: now,
|
|
invokedAt: null
|
|
})
|
|
]
|
|
|
|
expect(buildGoalStateMessages(messages).map((message) => message.id))
|
|
.toEqual(['local-immediate'])
|
|
})
|
|
|
|
it('uses every canonical message even when the thread hides queued rows', () => {
|
|
const now = 1_700_000_000_000
|
|
const messages = [
|
|
userMessage({ id: 'visible', createdAt: now - 10 }),
|
|
userMessage({ id: 'pending', createdAt: now })
|
|
]
|
|
|
|
expect(buildGoalStateMessages(messages).map((message) => message.id))
|
|
.toEqual(['visible', 'pending'])
|
|
})
|
|
|
|
it('ignores uninvoked scheduled messages, including mature prompts, until they are invoked', () => {
|
|
const now = 1_700_000_000_000
|
|
const futureQueued = userMessage({
|
|
id: 'future',
|
|
createdAt: now,
|
|
invokedAt: null,
|
|
scheduledAt: now + 60_000
|
|
})
|
|
const matureQueued = userMessage({
|
|
id: 'mature',
|
|
createdAt: now + 1,
|
|
invokedAt: null,
|
|
scheduledAt: now - 60_000
|
|
})
|
|
const invokedScheduled = userMessage({
|
|
id: 'invoked',
|
|
createdAt: now + 2,
|
|
invokedAt: now + 30_000,
|
|
scheduledAt: now - 60_000
|
|
})
|
|
|
|
expect(buildGoalStateMessages([futureQueued, matureQueued, invokedScheduled]).map((message) => message.id))
|
|
.toEqual(['invoked'])
|
|
})
|
|
})
|