diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts
index 87bb967c4..53026b53c 100644
--- a/frontend/src/api/opencode.test.ts
+++ b/frontend/src/api/opencode.test.ts
@@ -392,6 +392,21 @@ describe('OpenCode facade', () => {
expect((failure as FetchError).message).toBe('Session not found')
})
+ it('preserves the declared error tag as FetchError.code', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({ _tag: 'PermissionNotFoundError', sessionID: 'ses_1', requestID: 'per_1', message: 'Permission request not found' }),
+ { status: 404, headers: { 'Content-Type': 'application/json' } },
+ ),
+ )
+
+ const failure = await replyPermission('ses_1', 'per_1', 'once').catch((error: unknown) => error)
+
+ expect(failure).toBeInstanceOf(FetchError)
+ expect((failure as FetchError).statusCode).toBe(404)
+ expect((failure as FetchError).code).toBe('PermissionNotFoundError')
+ })
+
it('maps a declared V2 conflict error to 409', async () => {
fetchMock.mockResolvedValue(
new Response(
diff --git a/frontend/src/components/repo/WorktreeSessionGroups.tsx b/frontend/src/components/repo/WorktreeSessionGroups.tsx
index caf97543d..6617639a3 100644
--- a/frontend/src/components/repo/WorktreeSessionGroups.tsx
+++ b/frontend/src/components/repo/WorktreeSessionGroups.tsx
@@ -179,7 +179,7 @@ export function WorktreeSessionGroups({
{label}
{sourceLabel && source && (
-
{sourceLabel}
+
{sourceLabel}
)}
{isInUse && (
diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx
index ad99aca99..b0f775970 100644
--- a/frontend/src/contexts/EventContext.test.tsx
+++ b/frontend/src/contexts/EventContext.test.tsx
@@ -1,10 +1,12 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import type { ReactNode } from 'react'
+import { useState, type ReactNode } from 'react'
import { MemoryRouter, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FormInfo, PermissionRequest } from '@opencode-manager/shared/opencode'
+import { FetchError } from '@opencode-manager/shared'
+import { showToast } from '@/lib/toast'
import { useSessionStatus } from '@/stores/sessionStatusStore'
import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough'
import { EventProvider, useEventContext, useForms, usePermissions, useSSEHealth } from './EventContext'
@@ -95,9 +97,14 @@ function Harness() {
const { current, pendingCount, syncForSession, navigateToCurrent, cancel, reply, getForSession } = useForms()
const permissions = usePermissions()
const location = useLocation()
+ const [rejection, setRejection] = useState('none')
+ const recordRejection = (action: Promise) => {
+ action.catch((error: unknown) => setRejection(error instanceof FetchError ? error.code ?? 'unknown' : 'unknown'))
+ }
return (
+
{rejection}
{pendingCount}
{current?.id ?? 'none'}
{getForSession('session-1')?.id ?? 'none'}
@@ -116,9 +123,9 @@ function Harness() {
-
-
-
+
+
+
)
@@ -248,6 +255,88 @@ describe('EventProvider permissions and forms', () => {
})
})
+ it('removes a permission the server no longer knows about when replying', async () => {
+ mocks.listPendingPermissions.mockResolvedValue([pendingPermission])
+ mocks.replyPermission.mockRejectedValue(new FetchError('Permission request not found', 404, 'PermissionNotFoundError'))
+
+ render(, { wrapper: createWrapper() })
+
+ await userEvent.click(screen.getByRole('button', { name: 'Sync Permissions' }))
+
+ await waitFor(() => expect(screen.getByTestId('permission-count')).toHaveTextContent('1'))
+
+ await userEvent.click(screen.getByRole('button', { name: 'Reject Permission' }))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('permission-count')).toHaveTextContent('0')
+ expect(screen.getByTestId('permission-current')).toHaveTextContent('none')
+ })
+ expect(showToast.info).toHaveBeenCalledWith('Permission request expired')
+ expect(screen.getByTestId('rejection')).toHaveTextContent('none')
+ })
+
+ it('keeps a permission and rejects when the reply fails for another reason', async () => {
+ mocks.listPendingPermissions.mockResolvedValue([pendingPermission])
+ mocks.replyPermission.mockRejectedValue(new FetchError('Session not found', 404, 'SessionNotFoundError'))
+
+ render(, { wrapper: createWrapper() })
+
+ await userEvent.click(screen.getByRole('button', { name: 'Sync Permissions' }))
+
+ await waitFor(() => expect(screen.getByTestId('permission-count')).toHaveTextContent('1'))
+
+ await userEvent.click(screen.getByRole('button', { name: 'Reject Permission' }))
+
+ await waitFor(() => expect(screen.getByTestId('rejection')).toHaveTextContent('SessionNotFoundError'))
+ expect(screen.getByTestId('permission-count')).toHaveTextContent('1')
+ expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-1')
+ expect(showToast.info).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['Reply', 'replyForm'],
+ ['Dismiss', 'cancelForm'],
+ ] as const)('removes a form the server no longer knows about on %s', async (button, mock) => {
+ mocks.listPendingForms.mockResolvedValue([pendingForm])
+ mocks[mock].mockRejectedValue(new FetchError('Form not found', 404, 'FormNotFoundError'))
+
+ render(, { wrapper: createWrapper() })
+
+ await userEvent.click(screen.getByRole('button', { name: 'Sync' }))
+
+ await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('1'))
+
+ await userEvent.click(screen.getByRole('button', { name: button }))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('count')).toHaveTextContent('0')
+ expect(screen.getByTestId('current')).toHaveTextContent('none')
+ })
+ expect(showToast.info).toHaveBeenCalledWith('Form expired')
+ expect(screen.getByTestId('rejection')).toHaveTextContent('none')
+ })
+
+ it.each([
+ ['Reply', 'replyForm'],
+ ['Dismiss', 'cancelForm'],
+ ] as const)('keeps a form and rejects when %s fails for another reason', async (button, mock) => {
+ mocks.listPendingForms.mockResolvedValue([pendingForm])
+ mocks[mock].mockRejectedValue(new FetchError('Form already settled', 409, 'FormAlreadySettledError'))
+
+ render(, { wrapper: createWrapper() })
+
+ await userEvent.click(screen.getByRole('button', { name: 'Sync' }))
+
+ await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('1'))
+
+ await userEvent.click(screen.getByRole('button', { name: button }))
+
+ await waitFor(() => expect(screen.getByTestId('rejection')).toHaveTextContent('FormAlreadySettledError'))
+ expect(screen.getByTestId('count')).toHaveTextContent('1')
+ expect(screen.getByTestId('current')).toHaveTextContent('form-1')
+ expect(showToast.info).not.toHaveBeenCalled()
+ })
+
it('forwards an optional rejection message to the facade', async () => {
mocks.listPendingPermissions.mockResolvedValue([pendingPermission])
diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx
index 926a6d850..bcef20aa2 100644
--- a/frontend/src/contexts/EventContext.tsx
+++ b/frontend/src/contexts/EventContext.tsx
@@ -11,6 +11,7 @@ import {
replyPermission,
} from '@/api/opencode'
import { listRepos } from '@/api/repos'
+import { FetchError } from '@opencode-manager/shared'
import type { FormAnswer, FormInfo, PermissionRequest, V2Event } from '@opencode-manager/shared/opencode'
import type { PermissionResponse, SSHHostKeyRequest, Repo } from '@/api/types'
import { showToast } from '@/lib/toast'
@@ -94,6 +95,11 @@ interface EventContextValue {
permissions: {
current: PermissionRequest | null
pendingCount: number
+ /**
+ * Replies to a permission request and removes it from the queue. Resolves, after showing an
+ * "expired" toast, when the server no longer knows the request (`PermissionNotFoundError`);
+ * rejects on any other failure and keeps the request queued.
+ */
respond: (
permissionID: string,
sessionID: string,
@@ -111,7 +117,15 @@ interface EventContextValue {
forms: {
current: FormInfo | null
pendingCount: number
+ /**
+ * Answers a form and removes it from the queue. Resolves, after showing an "expired" toast,
+ * when the server no longer knows the form (`FormNotFoundError`); rejects on any other failure.
+ */
reply: (formID: string, answer: FormAnswer) => Promise
+ /**
+ * Dismisses a form on the server and removes it from the queue, with the same
+ * resolve-on-`FormNotFoundError` contract as `reply`.
+ */
cancel: (formID: string) => Promise
dismiss: (formID: string, sessionID?: string) => void
getForSession: (sessionID: string) => FormInfo | null
@@ -123,6 +137,24 @@ interface EventContextValue {
getRepoIdForSession: (sessionID: string) => number | null
}
+/**
+ * Sends a reply to a pending server request and treats the request as settled when the server
+ * reports it no longer exists (`goneCode`), showing `expiredMessage` instead of rejecting.
+ * Every other failure is rethrown.
+ */
+async function settlePendingReply(
+ reply: () => Promise,
+ goneCode: string,
+ expiredMessage: string,
+): Promise {
+ try {
+ await reply()
+ } catch (error) {
+ if (!(error instanceof FetchError && error.code === goneCode)) throw error
+ showToast.info(expiredMessage)
+ }
+}
+
const EventContext = createContext(null)
export function EventProvider({ children }: { children: React.ReactNode }) {
@@ -284,21 +316,25 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
response: PermissionResponse,
message?: string,
) => {
- await replyPermission(sessionID, permissionID, response, message)
+ await settlePendingReply(
+ () => replyPermission(sessionID, permissionID, response, message),
+ 'PermissionNotFoundError',
+ 'Permission request expired',
+ )
removePermission(permissionID, sessionID)
}, [removePermission])
const replyToForm = useCallback(async (formID: string, answer: FormAnswer) => {
const form = Object.values(formsBySession).flat().find(f => f.id === formID)
if (!form) throw new Error('Form not found')
- await replyForm(form.sessionID, formID, answer)
+ await settlePendingReply(() => replyForm(form.sessionID, formID, answer), 'FormNotFoundError', 'Form expired')
removeForm(formID, form.sessionID)
}, [formsBySession, removeForm])
const cancelPendingForm = useCallback(async (formID: string) => {
const form = Object.values(formsBySession).flat().find(f => f.id === formID)
if (!form) throw new Error('Form not found')
- await cancelForm(form.sessionID, formID)
+ await settlePendingReply(() => cancelForm(form.sessionID, formID), 'FormNotFoundError', 'Form expired')
removeForm(formID, form.sessionID)
}, [formsBySession, removeForm])