mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user