fix(web,hub): surface inactive-session error on text-only send (closes #918) (#922)

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:
HeavyGee
2026-06-18 10:11:40 +08:00
committed by GitHub
co-authored by Cursor
parent fc8c32e07a
commit a858256620
8 changed files with 294 additions and 19 deletions
+109 -1
View File
@@ -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)
+16
View File
@@ -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