Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 90 additions & 14 deletions web/src/hooks/mutations/useSendMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,21 +158,48 @@ describe('useSendMessage', () => {
await expect(acceptedPromise!).resolves.toBe(false)
})

it('resolves false when resolveSessionId throws (inactive-session resume failure)', async () => {
const api = createMockApi()
it('resolves false and keeps a failed prompt bubble when resolveSessionId throws', async () => {
const sendMock = vi.fn()
const api = createMockApi(sendMock)
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)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { appendOptimisticMessage } = await import('@/lib/message-window-store')
try {
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)
expect(sendMock).not.toHaveBeenCalled()
expect(appendOptimisticMessage).toHaveBeenCalledWith(
'session-A',
expect.objectContaining({
id: 'local-id-1',
localId: 'local-id-1',
invokedAt: null,
status: 'failed',
originalText: 'hello',
content: {
role: 'user',
content: {
type: 'text',
text: 'hello',
attachments: undefined,
}
},
})
)
expect(consoleError).toHaveBeenCalledWith('Failed to resolve session before send:', resumeError)
} finally {
consoleError.mockRestore()
}
})

it('resolves true after async resolveSessionId succeeds and mutation starts', async () => {
Expand Down Expand Up @@ -234,4 +261,53 @@ describe('useSendMessage', () => {
scheduledAt,
)
})

it('resolves the inactive session before retrying a preserved failed prompt', async () => {
const sendMock = vi.fn(async () => {})
const onSessionResolved = vi.fn()
const resolveSessionId = vi.fn(async () => 'session-resolved')
const api = createMockApi(sendMock)
const { getMessageWindowState, updateMessageStatus } = await import('@/lib/message-window-store')
vi.mocked(getMessageWindowState).mockReturnValueOnce({
messages: [{
id: 'local-retry-1',
seq: null,
localId: 'local-retry-1',
content: { role: 'user', content: { type: 'text', text: 'preserved prompt' } },
createdAt: 1_000,
invokedAt: null,
scheduledAt: null,
status: 'failed',
originalText: 'preserved prompt',
} as never],
pending: [],
} as never)

const { result } = renderHook(
() => useSendMessage(api, 'session-A', {
resolveSessionId,
onSessionResolved,
}),
{ wrapper: createWrapper() },
)

act(() => {
expect(result.current.retryMessage('local-retry-1')).toBe(true)
})

await waitFor(() => {
expect(sendMock).toHaveBeenCalled()
})

expect(resolveSessionId).toHaveBeenCalledWith('session-A')
expect(onSessionResolved).toHaveBeenCalledWith('session-resolved')
expect(updateMessageStatus).toHaveBeenCalledWith('session-A', 'local-retry-1', 'sending')
expect(sendMock).toHaveBeenCalledWith(
'session-resolved',
'preserved prompt',
'local-retry-1',
undefined,
null,
)
})
})
55 changes: 47 additions & 8 deletions web/src/hooks/mutations/useSendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type UseSendMessageOptions = {

/** Create an optimistic message for display. Extracted as an extension point
* so a future floating-UI PR can route queued messages to a separate area. */
function createOptimisticMessage(input: SendMessageInput, status: 'queued' | 'sending'): DecryptedMessage {
function createOptimisticMessage(input: SendMessageInput, status: 'queued' | 'sending' | 'failed'): DecryptedMessage {
return {
id: input.localId,
seq: null,
Expand Down Expand Up @@ -145,6 +145,21 @@ export function useSendMessage(
targetSessionId = resolved
}
} catch (error) {
// The composer has already cleared its text by the time async
// inactive-session resume fails. Surface a failed local prompt
// bubble so 500/502 resume errors cannot make the user's prompt
// disappear with no way to copy or retry it.
appendOptimisticMessage(
sessionId,
createOptimisticMessage({
sessionId,
text,
localId,
createdAt,
attachments,
scheduledAt,
}, 'failed')
)
haptic.notification('error')
console.error('Failed to resolve session before send:', error)
return false
Expand Down Expand Up @@ -182,16 +197,40 @@ export function useSendMessage(

const message = findMessageByLocalId(sessionId, localId)
if (!message?.originalText) return false
const originalText = message.originalText

updateMessageStatus(sessionId, localId, 'sending')

mutation.mutate({
sessionId,
text: message.originalText,
localId,
createdAt: message.createdAt,
scheduledAt: message.scheduledAt ?? null,
})
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) {
updateMessageStatus(sessionId, localId, 'failed')
haptic.notification('error')
console.error('Failed to resolve session before retry:', error)
return
} finally {
resolveGuardRef.current = false
setIsResolving(false)
}
}

mutation.mutate({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Retrying a prompt preserved by the new resolve-failure path drops its attachments. createOptimisticMessage now stores attachments in the failed bubble, but this retry mutation does not pass them back to api.sendMessage, so image/file prompts get retried as text-only; attachment-only prompts are still rejected by the originalText guard.

Suggested fix:

const message = findMessageByLocalId(sessionId, localId)
if (!message) return false

const content = message.content
const userContent = content
    && typeof content === 'object'
    && 'role' in content
    && (content as { role?: unknown }).role === 'user'
    && 'content' in content
    ? (content as { content?: unknown }).content
    : undefined
const attachments = userContent
    && typeof userContent === 'object'
    && 'attachments' in userContent
    ? (userContent as { attachments?: AttachmentMetadata[] }).attachments
    : undefined
const originalText = message.originalText ?? ''
if (!originalText && !attachments?.length) return false

mutation.mutate({
    sessionId: targetSessionId,
    text: originalText,
    localId,
    createdAt: message.createdAt,
    attachments,
    scheduledAt: message.scheduledAt ?? null,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Retrying a prompt preserved by the new resolve-failure path still drops its attachments. createOptimisticMessage stores attachments in the failed bubble, but this retry mutation does not pass them back to api.sendMessage; text+file prompts retry as text-only, and attachment-only prompts are rejected by the originalText guard.

Suggested fix:

const message = findMessageByLocalId(sessionId, localId)
if (!message) return false

const userContent = message.content?.role === 'user'
    ? message.content.content
    : undefined
const attachments = userContent?.type === 'text'
    ? userContent.attachments
    : undefined
const originalText = message.originalText ?? ''
if (!originalText && !attachments?.length) return false

mutation.mutate({
    sessionId: targetSessionId,
    text: originalText,
    localId,
    createdAt: message.createdAt,
    attachments,
    scheduledAt: message.scheduledAt ?? null,
})

sessionId: targetSessionId,
text: originalText,
localId,
createdAt: message.createdAt,
scheduledAt: message.scheduledAt ?? null,
})
})()
return true
}

Expand Down
Loading