mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(hub,web): support scheduling messages for future delivery (#590)
This commit is contained in:
@@ -117,4 +117,121 @@ describe('useSendMessage', () => {
|
||||
expect(onBlocked).toHaveBeenCalledWith('no-api')
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves true when the send is accepted', async () => {
|
||||
const api = createMockApi()
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-A'),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('resolves false when blocked (no api) so the caller can preserve schedule state', async () => {
|
||||
const onBlocked = vi.fn()
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(null, 'session-A', { onBlocked }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(false)
|
||||
expect(onBlocked).toHaveBeenCalledWith('no-api')
|
||||
})
|
||||
|
||||
it('resolves false when blocked (no session)', async () => {
|
||||
const api = createMockApi()
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, null),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('resolves false when resolveSessionId throws (inactive-session resume failure)', async () => {
|
||||
const api = createMockApi()
|
||||
const resumeError = new Error('resume failed')
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-A', {
|
||||
resolveSessionId: async () => { throw resumeError },
|
||||
onSessionResolved: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('resolves true after async resolveSessionId succeeds and mutation starts', async () => {
|
||||
const api = createMockApi()
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-original', {
|
||||
resolveSessionId: async () => 'session-resolved',
|
||||
onSessionResolved: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
let acceptedPromise: Promise<boolean> | undefined
|
||||
act(() => {
|
||||
acceptedPromise = result.current.sendMessage('hello')
|
||||
})
|
||||
await expect(acceptedPromise!).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('preserves scheduledAt when retrying a failed scheduled message', async () => {
|
||||
const sendMock = vi.fn(async () => {})
|
||||
const api = createMockApi(sendMock)
|
||||
const scheduledAt = Date.now() + 5 * 60_000
|
||||
|
||||
const { getMessageWindowState } = await import('@/lib/message-window-store')
|
||||
vi.mocked(getMessageWindowState).mockReturnValueOnce({
|
||||
messages: [],
|
||||
pending: [{
|
||||
id: 'local-retry-1',
|
||||
seq: null,
|
||||
localId: 'local-retry-1',
|
||||
content: { role: 'user', content: { type: 'text', text: 'hi later' } },
|
||||
createdAt: 1_000,
|
||||
invokedAt: null,
|
||||
scheduledAt,
|
||||
status: 'failed',
|
||||
originalText: 'hi later',
|
||||
} as never],
|
||||
} as never)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-A'),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.retryMessage('local-retry-1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// api.sendMessage(sessionId, text, localId, attachments, scheduledAt)
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
'session-A',
|
||||
'hi later',
|
||||
'local-retry-1',
|
||||
undefined,
|
||||
scheduledAt,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ type SendMessageInput = {
|
||||
localId: string
|
||||
createdAt: number
|
||||
attachments?: AttachmentMetadata[]
|
||||
scheduledAt?: number | null
|
||||
}
|
||||
|
||||
type BlockedReason = 'no-api' | 'no-session' | 'pending'
|
||||
@@ -48,6 +49,7 @@ function createOptimisticMessage(input: SendMessageInput, status: 'queued' | 'se
|
||||
// response that omits the field entirely (`undefined`) is treated as
|
||||
// already-invoked and stays in the thread, not the floating bar.
|
||||
invokedAt: null,
|
||||
scheduledAt: input.scheduledAt ?? null,
|
||||
status,
|
||||
originalText: input.text,
|
||||
}
|
||||
@@ -72,8 +74,14 @@ export function useSendMessage(
|
||||
sessionId: string | null,
|
||||
options?: UseSendMessageOptions
|
||||
): {
|
||||
sendMessage: (text: string, attachments?: AttachmentMetadata[]) => void
|
||||
retryMessage: (localId: string) => void
|
||||
// Resolves true when a mutation was actually started, false when the call was
|
||||
// rejected pre-mutation (no-api / no-session / pending) OR the async
|
||||
// resolveSessionId step threw. Async is required because inactive-session
|
||||
// resume happens before mutation.mutate(), and a sync `true` would let the
|
||||
// caller clear UI state (e.g. pendingSchedule) before knowing whether
|
||||
// resume succeeded — see SessionChat.handleSend.
|
||||
sendMessage: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise<boolean>
|
||||
retryMessage: (localId: string) => boolean
|
||||
isSending: boolean
|
||||
} {
|
||||
const { haptic } = usePlatform()
|
||||
@@ -87,7 +95,7 @@ export function useSendMessage(
|
||||
if (!api) {
|
||||
throw new Error('API unavailable')
|
||||
}
|
||||
await api.sendMessage(input.sessionId, input.text, input.localId, input.attachments)
|
||||
await api.sendMessage(input.sessionId, input.text, input.localId, input.attachments, input.scheduledAt)
|
||||
},
|
||||
onMutate: async (input) => {
|
||||
const status = isSessionThinkingRef.current ? 'queued' as const : 'sending' as const
|
||||
@@ -109,71 +117,71 @@ export function useSendMessage(
|
||||
},
|
||||
})
|
||||
|
||||
const sendMessage = (text: string, attachments?: AttachmentMetadata[]) => {
|
||||
const sendMessage = async (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null): Promise<boolean> => {
|
||||
if (!api) {
|
||||
options?.onBlocked?.('no-api')
|
||||
haptic.notification('error')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!sessionId) {
|
||||
options?.onBlocked?.('no-session')
|
||||
haptic.notification('error')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (mutation.isPending || resolveGuardRef.current) {
|
||||
options?.onBlocked?.('pending')
|
||||
return
|
||||
return false
|
||||
}
|
||||
const localId = makeClientSideId('local')
|
||||
const createdAt = Date.now()
|
||||
void (async () => {
|
||||
let targetSessionId = sessionId
|
||||
if (options?.resolveSessionId) {
|
||||
resolveGuardRef.current = true
|
||||
setIsResolving(true)
|
||||
try {
|
||||
const resolved = await options.resolveSessionId(sessionId)
|
||||
if (resolved && resolved !== sessionId) {
|
||||
options.onSessionResolved?.(resolved)
|
||||
targetSessionId = resolved
|
||||
}
|
||||
} catch (error) {
|
||||
haptic.notification('error')
|
||||
console.error('Failed to resolve session before send:', error)
|
||||
return
|
||||
} finally {
|
||||
resolveGuardRef.current = false
|
||||
setIsResolving(false)
|
||||
let targetSessionId = sessionId
|
||||
if (options?.resolveSessionId) {
|
||||
resolveGuardRef.current = true
|
||||
setIsResolving(true)
|
||||
try {
|
||||
const resolved = await options.resolveSessionId(sessionId)
|
||||
if (resolved && resolved !== sessionId) {
|
||||
options.onSessionResolved?.(resolved)
|
||||
targetSessionId = resolved
|
||||
}
|
||||
} catch (error) {
|
||||
haptic.notification('error')
|
||||
console.error('Failed to resolve session before send:', error)
|
||||
return false
|
||||
} finally {
|
||||
resolveGuardRef.current = false
|
||||
setIsResolving(false)
|
||||
}
|
||||
mutation.mutate({
|
||||
sessionId: targetSessionId,
|
||||
text,
|
||||
localId,
|
||||
createdAt,
|
||||
attachments,
|
||||
})
|
||||
})()
|
||||
}
|
||||
mutation.mutate({
|
||||
sessionId: targetSessionId,
|
||||
text,
|
||||
localId,
|
||||
createdAt,
|
||||
attachments,
|
||||
scheduledAt,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const retryMessage = (localId: string) => {
|
||||
const retryMessage = (localId: string): boolean => {
|
||||
if (!api) {
|
||||
options?.onBlocked?.('no-api')
|
||||
haptic.notification('error')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!sessionId) {
|
||||
options?.onBlocked?.('no-session')
|
||||
haptic.notification('error')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (mutation.isPending || resolveGuardRef.current) {
|
||||
options?.onBlocked?.('pending')
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const message = findMessageByLocalId(sessionId, localId)
|
||||
if (!message?.originalText) return
|
||||
if (!message?.originalText) return false
|
||||
|
||||
updateMessageStatus(sessionId, localId, 'sending')
|
||||
|
||||
@@ -182,7 +190,9 @@ export function useSendMessage(
|
||||
text: message.originalText,
|
||||
localId,
|
||||
createdAt: message.createdAt,
|
||||
scheduledAt: message.scheduledAt ?? null,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user