mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
Sending text via the web composer to an archived/inactive session
silently dropped on the floor: the hub returned 409 but the web client
swallowed the failure with a console.error in the resolveSessionId catch
branch, leaving the operator with no signal and no recovery path.
Hub: add a machine-readable `code: 'session_inactive'` to the 409 body
so the web client can discriminate this branch without string-matching
the i18n-able human message.
Web (router.tsx, useSendMessage.ts, HappyComposer.tsx):
- useSendMessage now fires `onError` on resolveSessionId rejection,
not just on POST /messages failure -- closes the visibility hole
when the inactive session has no resume target or resume itself
fails.
- The route classifies the thrown error: a 409 + session_inactive
code or a synthetic ApiError thrown from resolveSessionId attaches
a Reopen action to the existing inline composer-error affordance.
Plain 4xx / 5xx / network keep the legacy text-restore UX
untouched.
- Reopen calls api.reopenSession (the same path as SessionList's
Reopen menu item), invalidates the session queries, and navigates
to the resumed sessionId. Per the orchestrator brief's friction
pass on #917 the affordance does NOT auto-replay the send; the
operator re-clicks Send on the restored composer text.
Tests:
- hub messages.test.ts: 409 carries `code: 'session_inactive'`.
- useSendMessage.test.tsx: ApiError(409, session_inactive) from POST
flows through onError; resolveSessionId rejection flows through
onError keyed by the original sessionId; 500 keeps the legacy
fallback path with no code attached.
AI disclosure: implemented by an AI agent (Claude Opus 4.7) acting on
operator instructions; tests pass locally (bun typecheck + bun run
test for hub and web).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useSendMessage } from './useSendMessage'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import { ApiError, type ApiClient } from '@/api/client'
|
||||
|
||||
vi.mock('@/lib/message-window-store', () => ({
|
||||
appendOptimisticMessage: vi.fn(),
|
||||
@@ -523,6 +523,114 @@ describe('useSendMessage', () => {
|
||||
await expect(acceptedPromise!).resolves.toBe(true)
|
||||
})
|
||||
|
||||
// #918: the inactive-session 409 path
|
||||
describe('inactive-session 409 (issue #918)', () => {
|
||||
it('fires onError with the ApiError so the consumer can render a session_inactive affordance', async () => {
|
||||
// Hub returns 409 with code: 'session_inactive' (guards.ts).
|
||||
// The api client throws ApiError(status=409, code='session_inactive').
|
||||
const onError = vi.fn()
|
||||
const api = createMockApi(async () => {
|
||||
throw new ApiError(
|
||||
'HTTP 409 Conflict: {"error":"Session is inactive","code":"session_inactive"}',
|
||||
409,
|
||||
'session_inactive',
|
||||
'{"error":"Session is inactive","code":"session_inactive"}'
|
||||
)
|
||||
})
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-A', { onError }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage('hello inactive')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
const info = onError.mock.calls[0][0] as { text: string; error: unknown; sessionId: string }
|
||||
expect(info.text).toBe('hello inactive')
|
||||
expect(info.sessionId).toBe('session-A')
|
||||
expect(info.error).toBeInstanceOf(ApiError)
|
||||
const apiErr = info.error as ApiError
|
||||
expect(apiErr.status).toBe(409)
|
||||
expect(apiErr.code).toBe('session_inactive')
|
||||
})
|
||||
|
||||
it('fires onError when resolveSessionId rejects (pre-mutation inactive-session failure)', async () => {
|
||||
// Pre-mutation: the route's resolveSessionId throws when
|
||||
// inactiveSessionCanResume returns false OR api.resumeSession
|
||||
// fails. Prior to #918 this dropped the typed text into the
|
||||
// void with only a console.error; the operator saw nothing.
|
||||
// The hook must surface this through onError too.
|
||||
const onError = vi.fn()
|
||||
const api = createMockApi()
|
||||
const resumeError = new ApiError('Session is inactive', 409, 'session_inactive')
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-A', {
|
||||
onError,
|
||||
resolveSessionId: async () => { throw resumeError },
|
||||
onSessionResolved: vi.fn(),
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage('hello pre-mutation')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
const info = onError.mock.calls[0][0] as { text: string; error: unknown; sessionId: string }
|
||||
expect(info.text).toBe('hello pre-mutation')
|
||||
// Keyed by the ORIGINAL sessionId: pre-mutation never navigated.
|
||||
expect(info.sessionId).toBe('session-A')
|
||||
expect(info.error).toBe(resumeError)
|
||||
})
|
||||
|
||||
it('5xx still uses the legacy text-restore path (#918 must not regress transient-failure UX)', async () => {
|
||||
// Acceptance criterion: a real transient 500/network failure
|
||||
// must keep the original behavior (remove optimistic row,
|
||||
// onError fires with the plain message), not adopt the
|
||||
// session_inactive affordance.
|
||||
const onError = vi.fn()
|
||||
const api = createMockApi(async () => {
|
||||
throw new ApiError(
|
||||
'HTTP 500 Internal Server Error',
|
||||
500,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
const { removeOptimisticMessage } = await import('@/lib/message-window-store')
|
||||
const removeMock = vi.mocked(removeOptimisticMessage)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useSendMessage(api, 'session-A', { onError }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.sendMessage('hello transient')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(removeMock).toHaveBeenCalledWith('session-A', 'local-id-1')
|
||||
const info = onError.mock.calls[0][0] as { error: unknown }
|
||||
// No session_inactive code -> consumer renders fallback
|
||||
// message, no Reopen action attached.
|
||||
expect((info.error as ApiError).code).toBeUndefined()
|
||||
expect((info.error as ApiError).status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves scheduledAt when retrying a failed scheduled message', async () => {
|
||||
const sendMock = vi.fn(async () => {})
|
||||
const api = createMockApi(sendMock)
|
||||
|
||||
@@ -235,6 +235,22 @@ export function useSendMessage(
|
||||
} catch (error) {
|
||||
haptic.notification('error')
|
||||
console.error('Failed to resolve session before send:', error)
|
||||
// #918: surface the failure via onError so the route can render
|
||||
// an inline affordance instead of silently swallowing the
|
||||
// typed text. This covers the "no resume target" branch
|
||||
// (inactiveSessionCanResume === false) and also any failure
|
||||
// from api.resumeSession itself. The mutation never started
|
||||
// (no optimistic row to clean up); onError is the only
|
||||
// visibility hook the consumer has for this pre-mutation
|
||||
// path. Key by the ORIGINAL sessionId because navigation
|
||||
// hasn't happened yet -- the operator is still on the
|
||||
// archived session's route.
|
||||
options?.onError?.({
|
||||
sessionId,
|
||||
text,
|
||||
error,
|
||||
scheduledAt: scheduledAt ?? null
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
resolveGuardRef.current = false
|
||||
|
||||
Reference in New Issue
Block a user