feat(hub+web): add POST /sessions/:id/reopen + Reopen button on inactive rows (#826)

* feat(hub+web): add POST /sessions/:id/reopen + Reopen button on inactive rows

Archived sessions retain their full transcript and metadata in the DB, but
today there is no path back to them from the web UI; the only way to revive
one is shell access plus sqlite metadata patching plus a manual /resume call.

This change adds a single one-click affordance:

- Hub: new POST /api/sessions/:id/reopen route on the existing sessions
  router. The route delegates to a new engine method `reopenSession` that:
  - is idempotent (active session -> 200 with `resumed:false`),
  - validates Cursor sessions still have a `cursorSessionId` once they have
    any messages (otherwise we cannot resume the agent thread),
  - clears `lifecycleState='archived'`, `archivedBy`, `archiveReason` via a
    versioned metadata update, and stamps `lifecycleStateSince`,
  - defaults `cursorSessionProtocol='stream-json'` for pre-#799 Cursor
    sessions (sessions that have a `cursorSessionId` but no protocol set),
    so routing still reaches the legacy launcher; ACP sessions keep their
    explicit protocol,
  - forwards to the same `resumeSession` path the existing /resume route
    uses, including the `canFreshSpawnNeverStartedSession` fallback.

  422 is returned with `{ missing: [...] }` when the agent metadata needed
  to resume is gone; other engine errors map to 404/409/503/500 with the
  existing shape (mirrors /resume).

- Web: a "Reopen" entry in the SessionActionMenu that appears next to
  "Delete" on inactive sessions only. Wired into both the SessionList rows
  and the SessionHeader more-menu, with a small dismissable error dialog
  for the 422 missing-metadata case.

- Tests: route-level coverage for the four response shapes (200 reopen,
  200 idempotent, 404, 422) plus 409/503 error mappings; sessionCache
  tests for the archive-metadata clear (including the legacy Cursor
  protocol default); React component test for the menu item rendering on
  inactive vs active sessions; mutation hook test for the api wiring and
  the ApiError surface needed by the UI.

Closes #819

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(reopen): address codex review findings on fork PR #33

Four P2 findings from the cold-review bot, three fixed and one explained:

1. Mutation now returns the reopen response so the UI can route to a possibly
   different sessionId. SyncEngine.resumeSession may merge the row into a
   freshly-spawned session id (matching the send-message resume flow); the
   chat view now navigates there, the row list calls onSelect on the new id.
2. reopenSession on the client now goes through `request()` instead of a
   hand-rolled fetch, so 401 + onUnauthorized refresh works the same as
   every other session action. `request()` now throws `ApiError` (with
   status/code/body) on non-401 errors - backward compatible because
   ApiError extends Error.
3. (Reply only) Pre-#799 Cursor protocol propagates correctly without the
   extra plumbing the bot suggested: `clearSessionArchiveMetadata` writes
   `cursorSessionProtocol='stream-json'` to the DB; the CLI's
   `bootstrapExistingSession` preserves it via `pickExistingSessionMetadata`;
   if it's still absent at the launcher, `isLegacyCursorSession` defaults
   to stream-json whenever `cursorSessionId` is present.
4. Archive metadata is now restored when resume fails. `reopenSession`
   captures a snapshot of `lifecycleState`/`archivedBy`/`archiveReason`/
   `lifecycleStateSince` before the clear; if `resumeSession` returns an
   error (no machine online, spawn timeout, etc.), the snapshot is put
   back via the new `SessionCache.restoreSessionArchiveMetadata`. Engine
   test covers both the rollback and the no-rollback-on-success cases.

Error rendering helper moved to `web/src/lib/reopenError.ts` so the chat
header and the session row share one implementation, and gained a unit test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve engine error codes in ApiError.code on /reopen

`/sessions/:id/reopen` returns `{ error, code }` where `code` is the stable
taxonomy (`no_machine_online`, `resume_unavailable`, etc.) and `error` is the
human-readable message. The generic `request()` error path was reading only
`parsed.error`, so `ApiError.code` ended up being a message like
"No machine online" rather than `no_machine_online`, breaking taxonomy-based
branching in web callers.

`parseErrorCode` now prefers `parsed.code` and falls back to `parsed.error`
for legacy routes that only set `error`. Added api/client.test.ts covering
the three response shapes /reopen actually emits (503 with code, 500 without
code, 422 with missing[]).

Addresses upstream codex-action review on tiann/hapi#826.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(reopen): restore archive metadata exactly on rollback (drop fresh lifecycleStateSince)

For an archived session that predates `lifecycleStateSince` (the field is
absent from its metadata), `clearSessionArchiveMetadata` stamps a fresh
timestamp. If `resumeSession` then fails, the rollback was leaving that
fresh timestamp in place, making the rolled-back row look like it was
just archived rather than preserving the original lifecycle age.

`restoreSessionArchiveMetadata` now does an EXACT restore: when a snapshot
field is undefined the corresponding key on the metadata is deleted, not
left alone. Applies symmetrically to lifecycleState / archivedBy /
archiveReason / lifecycleStateSince. Test updated to assert the deletion
of the fresh timestamp.

Addresses upstream codex-action review on tiann/hapi#826.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-08 13:29:31 +08:00
committed by GitHub
co-authored by Cursor
parent cd99cfbc25
commit cb72703649
19 changed files with 1237 additions and 15 deletions
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { useSessionActions } from './useSessionActions'
import { ApiError, type ApiClient } from '@/api/client'
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
})
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
}
function createMockApi(reopenSession: (sessionId: string) => Promise<{ ok: true; sessionId: string; resumed: boolean }>): ApiClient {
return { reopenSession } as unknown as ApiClient
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('useSessionActions - reopenSession', () => {
it('invokes api.reopenSession with the session id and forwards the response', async () => {
const reopen = vi.fn(async (_sessionId: string) => ({
ok: true as const,
sessionId: 'session-A-spawned',
resumed: true
}))
const api = createMockApi(reopen)
const { result } = renderHook(
() => useSessionActions(api, 'session-A', 'cursor'),
{ wrapper: createWrapper() },
)
let response: { ok: true; sessionId: string; resumed: boolean } | undefined
await act(async () => {
response = await result.current.reopenSession()
})
expect(reopen).toHaveBeenCalledWith('session-A')
// The mutation must propagate the response so the UI can navigate to the
// possibly-new spawn id when resumeSession merges the row.
expect(response).toEqual({ ok: true, sessionId: 'session-A-spawned', resumed: true })
})
it('throws when api or sessionId is missing', async () => {
const { result } = renderHook(
() => useSessionActions(null, null, null),
{ wrapper: createWrapper() },
)
await expect(result.current.reopenSession()).rejects.toThrow('Session unavailable')
})
it('surfaces an ApiError so the UI can render the 422 missing-metadata payload', async () => {
const reopen = vi.fn(async () => {
throw new ApiError(
'HTTP 422 Unprocessable Entity: {"error":"Cursor session id is missing from metadata; reopen requires the original cursor chat id","missing":["cursorSessionId"]}',
422,
'Cursor session id is missing from metadata; reopen requires the original cursor chat id',
'{"error":"Cursor session id is missing from metadata; reopen requires the original cursor chat id","missing":["cursorSessionId"]}'
)
})
const api = createMockApi(reopen as unknown as ApiClient['reopenSession'])
const { result } = renderHook(
() => useSessionActions(api, 'session-X', 'cursor'),
{ wrapper: createWrapper() },
)
let captured: unknown
await act(async () => {
try {
await result.current.reopenSession()
} catch (error) {
captured = error
}
})
expect(captured).toBeInstanceOf(ApiError)
const apiError = captured as ApiError
expect(apiError.status).toBe(422)
expect(apiError.body).toContain('cursorSessionId')
await waitFor(() => {
// The hook should not get stuck pending after the failure.
expect(result.current.isPending).toBe(false)
})
})
})
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'
import type { ApiClient } from '@/api/client'
import type { CodexCollaborationMode, PermissionMode } from '@/types/api'
import type { ReopenSessionResponse } from '@hapi/protocol/apiTypes'
import { queryKeys } from '@/lib/query-keys'
import { clearMessageWindow } from '@/lib/message-window-store'
import { isKnownFlavor } from '@hapi/protocol'
@@ -14,6 +15,7 @@ export function useSessionActions(
): {
abortSession: () => Promise<void>
archiveSession: () => Promise<void>
reopenSession: () => Promise<ReopenSessionResponse>
switchSession: () => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void>
setCollaborationMode: (mode: CodexCollaborationMode) => Promise<void>
@@ -58,6 +60,16 @@ export function useSessionActions(
onSuccess: () => void invalidateSession(),
})
const reopenMutation = useMutation<ReopenSessionResponse, Error, void>({
mutationFn: async () => {
if (!api || !sessionId) {
throw new Error('Session unavailable')
}
return await api.reopenSession(sessionId)
},
onSuccess: () => void invalidateSession(),
})
const switchMutation = useMutation({
mutationFn: async () => {
if (!api || !sessionId) {
@@ -166,6 +178,7 @@ export function useSessionActions(
return {
abortSession: abortMutation.mutateAsync,
archiveSession: archiveMutation.mutateAsync,
reopenSession: reopenMutation.mutateAsync,
switchSession: switchMutation.mutateAsync,
setPermissionMode: permissionMutation.mutateAsync,
setCollaborationMode: collaborationMutation.mutateAsync,
@@ -176,6 +189,7 @@ export function useSessionActions(
deleteSession: deleteMutation.mutateAsync,
isPending: abortMutation.isPending
|| archiveMutation.isPending
|| reopenMutation.isPending
|| switchMutation.isPending
|| permissionMutation.isPending
|| collaborationMutation.isPending