Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/src/services/sse-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,20 +205,23 @@ class SSEAggregator {
}

private broadcastToDirectory(directory: string, event: string, data: string): void {
let clientData = data

try {
const parsed = JSON.parse(data) as SSEEvent
this.handleEvent(directory, parsed)
this.eventListeners.forEach(listener => {
try { listener(directory, parsed) } catch { /* ignore listener errors */ }
})
clientData = JSON.stringify({ ...parsed, directory })
} catch {
// Ignore parse errors
}

this.clients.forEach((client) => {
if (client.directories.has(directory)) {
try {
client.callback(event, data)
client.callback(event, clientData)
} catch (error) {
logger.error(`Failed to send to client ${client.id}:`, error)
}
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/api/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type CommandListResponse = paths['/command']['get']['responses']['200']['content
type CommandRequest = NonNullable<paths['/session/{sessionID}/command']['post']['requestBody']>['content']['application/json']
type ShellRequest = NonNullable<paths['/session/{sessionID}/shell']['post']['requestBody']>['content']['application/json']
type AgentListResponse = paths['/agent']['get']['responses']['200']['content']['application/json']
type PermissionListResponse = paths['/permission']['get']['responses']['200']['content']['application/json']
type QuestionListResponse = paths['/question']['get']['responses']['200']['content']['application/json']
type SendPromptResponse = paths['/session/{sessionID}/message']['post']['responses']['200']['content']['application/json']
type LspStatusResponse = paths['/lsp']['get']['responses']['200']['content']['application/json']
Expand Down Expand Up @@ -183,6 +184,12 @@ export class OpenCodeClient {
})
}

async listPendingPermissions() {
return fetchWrapper<PermissionListResponse>(`${this.baseURL}/permission`, {
params: this.getParams(),
})
}

async replyToQuestion(requestID: string, answers: string[][]) {
return fetchWrapper(`${this.baseURL}/question/${requestID}/reply`, {
method: 'POST',
Expand Down Expand Up @@ -248,4 +255,3 @@ export class OpenCodeClient {
export const createOpenCodeClient = (baseURL: string, directory?: string) => {
return new OpenCodeClient(baseURL, directory)
}

2 changes: 2 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export interface SSETodoUpdatedEvent {
export interface SSEPermissionAskedEvent {
type: 'permission.asked'
properties: PermissionRequest
directory?: string
}

export interface SSEPermissionRepliedEvent {
Expand All @@ -113,6 +114,7 @@ export interface SSEPermissionRepliedEvent {
export interface SSEQuestionAskedEvent {
type: 'question.asked'
properties: QuestionRequest
directory?: string
}

export interface SSEQuestionRepliedEvent {
Expand Down
262 changes: 262 additions & 0 deletions frontend/src/contexts/EventContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import { MemoryRouter, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { QuestionRequest } from '@/api/types'
import { EventProvider, useQuestions } from './EventContext'

const mocks = vi.hoisted(() => ({
listRepos: vi.fn(),
listPendingPermissions: vi.fn(),
listPendingQuestions: vi.fn(),
replyToQuestion: vi.fn(),
rejectQuestion: vi.fn(),
subscribeToSSE: vi.fn(),
addSSEDirectory: vi.fn(),
ensureSSEConnected: vi.fn(),
}))

vi.mock('@/api/repos', () => ({
listRepos: mocks.listRepos,
}))

vi.mock('@/api/opencode', () => ({
OpenCodeClient: vi.fn(() => ({
listPendingPermissions: mocks.listPendingPermissions,
listPendingQuestions: mocks.listPendingQuestions,
replyToQuestion: mocks.replyToQuestion,
rejectQuestion: mocks.rejectQuestion,
})),
}))

vi.mock('@/lib/sseManager', () => ({
subscribeToSSE: mocks.subscribeToSSE,
addSSEDirectory: mocks.addSSEDirectory,
ensureSSEConnected: mocks.ensureSSEConnected,
}))

vi.mock('@/lib/toast', () => ({
showToast: {
error: vi.fn(),
info: vi.fn(),
},
}))

const pendingQuestion: QuestionRequest = {
id: 'question-1',
sessionID: 'session-1',
questions: [
{
question: 'Continue?',
header: 'Confirm',
options: [
{
label: 'Yes',
description: 'Continue',
},
],
multiple: false,
},
],
}

const secondPendingQuestion: QuestionRequest = {
id: 'question-2',
sessionID: 'session-2',
questions: [
{
question: 'Deploy?',
header: 'Deploy',
options: [
{
label: 'Yes',
description: 'Deploy changes',
},
],
multiple: false,
},
],
}

function Harness() {
const { current, pendingCount, syncForSession, navigateToCurrent, reject, reply } = useQuestions()
const location = useLocation()

return (
<div>
<div data-testid="count">{pendingCount}</div>
<div data-testid="current">{current?.id ?? 'none'}</div>
<div data-testid="path">{location.pathname}</div>
<button onClick={() => syncForSession('/repo', 'session-1')}>Sync</button>
<button onClick={navigateToCurrent}>Navigate</button>
<button onClick={() => current && reject(current.id)}>Dismiss</button>
<button onClick={() => current && reply(current.id, [['Yes']])}>Reply</button>
</div>
)
}

function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})

return ({ children }: { children: ReactNode }) => (
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<EventProvider>{children}</EventProvider>
</QueryClientProvider>
</MemoryRouter>
)
}

describe('EventProvider questions', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listRepos.mockResolvedValue([])
mocks.listPendingPermissions.mockResolvedValue([])
mocks.listPendingQuestions.mockResolvedValue([])
mocks.replyToQuestion.mockResolvedValue(undefined)
mocks.rejectQuestion.mockResolvedValue(undefined)
mocks.subscribeToSSE.mockReturnValue(() => {})
mocks.addSSEDirectory.mockReturnValue(() => {})
mocks.ensureSSEConnected.mockResolvedValue(true)
})

it('syncs missed pending questions for a session', async () => {
mocks.listPendingQuestions.mockResolvedValue([pendingQuestion])

render(<Harness />, { wrapper: createWrapper() })

await userEvent.click(screen.getByRole('button', { name: 'Sync' }))

await waitFor(() => {
expect(screen.getByTestId('count')).toHaveTextContent('1')
expect(screen.getByTestId('current')).toHaveTextContent('question-1')
})
})

it('clears stale pending questions for a session', async () => {
mocks.listPendingQuestions
.mockResolvedValueOnce([pendingQuestion])
.mockResolvedValueOnce([])

render(<Harness />, { 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: 'Sync' }))

await waitFor(() => {
expect(screen.getByTestId('count')).toHaveTextContent('0')
expect(screen.getByTestId('current')).toHaveTextContent('none')
})
})

it('reconciles pending questions for the whole directory', async () => {
mocks.listPendingQuestions
.mockResolvedValueOnce([pendingQuestion, secondPendingQuestion])
.mockResolvedValueOnce([pendingQuestion])

render(<Harness />, { wrapper: createWrapper() })

await userEvent.click(screen.getByRole('button', { name: 'Sync' }))

await waitFor(() => {
expect(screen.getByTestId('count')).toHaveTextContent('2')
})

await userEvent.click(screen.getByRole('button', { name: 'Sync' }))

await waitFor(() => {
expect(screen.getByTestId('count')).toHaveTextContent('1')
expect(screen.getByTestId('current')).toHaveTextContent('question-1')
})
})

it('reconciles stale pending questions after reconnect', async () => {
mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }])
mocks.listPendingQuestions
.mockResolvedValueOnce([pendingQuestion])
.mockResolvedValueOnce([])

render(<Harness />, { wrapper: createWrapper() })

await waitFor(() => {
expect(screen.getByTestId('count')).toHaveTextContent('1')
})

const lastSubscribeCall = mocks.subscribeToSSE.mock.calls[mocks.subscribeToSSE.mock.calls.length - 1]
const handleStatusChange = lastSubscribeCall[1] as (connected: boolean) => void
handleStatusChange(true)

await waitFor(() => {
expect(screen.getByTestId('count')).toHaveTextContent('0')
expect(screen.getByTestId('current')).toHaveTextContent('none')
})
})

it('navigates to a synced pending question without session query cache', async () => {
mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }])
mocks.listPendingQuestions.mockResolvedValue([pendingQuestion])

render(<Harness />, { wrapper: createWrapper() })

await userEvent.click(screen.getByRole('button', { name: 'Sync' }))

await waitFor(() => {
expect(screen.getByTestId('current')).toHaveTextContent('question-1')
})

await userEvent.click(screen.getByRole('button', { name: 'Navigate' }))

await waitFor(() => {
expect(screen.getByTestId('path')).toHaveTextContent('/repos/123/sessions/session-1')
})
})

it('clears a pending question after dismiss succeeds', async () => {
mocks.listPendingQuestions.mockResolvedValue([pendingQuestion])

render(<Harness />, { 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: 'Dismiss' }))

await waitFor(() => {
expect(screen.getByTestId('count')).toHaveTextContent('0')
expect(screen.getByTestId('current')).toHaveTextContent('none')
})
})

it('clears a pending question after reply succeeds', async () => {
mocks.listPendingQuestions.mockResolvedValue([pendingQuestion])

render(<Harness />, { 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: 'Reply' }))

await waitFor(() => {
expect(screen.getByTestId('count')).toHaveTextContent('0')
expect(screen.getByTestId('current')).toHaveTextContent('none')
})
})
})
Loading
Loading