Skip to content
Open
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
26 changes: 13 additions & 13 deletions backend/src/routes/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ describe('oauth routes /auth-methods', () => {
expect(res.status).toBe(502)
expect(await res.json()).toEqual({ error: 'Failed to get provider auth methods', code: 'ClientError' })
})

it('waits for plugin activation before listing the auth methods', async () => {
const client = createStubOpenCodeClient()
const providerList = vi.mocked(client.api.provider.list)
const integrationList = vi.mocked(client.api.integration.list)
integrationList.mockResolvedValueOnce({ location: LOCATION, data: [integrationFixture()] })

const res = await createOAuthApp(client).request('/oauth/auth-methods')

expect(res.status).toBe(200)
expect(integrationList).toHaveBeenCalledTimes(1)
expect(providerList).toHaveBeenCalledBefore(integrationList)
})
})

describe('oauth routes /:id/oauth/authorize', () => {
Expand Down Expand Up @@ -192,19 +205,6 @@ describe('oauth routes /:id/oauth/authorize', () => {
})
})

it('retries once after a loading integration becomes available', async () => {
const client = createStubOpenCodeClient()
const connect = vi.mocked(client.api.integration.oauth.connect)
connect.mockRejectedValueOnce(taggedError('IntegrationNotFoundError', 'Integration not found: github-copilot'))

const res = await authorizeRequest(createOAuthApp(client))

expect(res.status).toBe(200)
expect((await readJson(res, OAuthAuthorizeResponseSchema)).attemptID).toBe('con_stub')
expect(connect).toHaveBeenCalledTimes(2)
expect(vi.mocked(client.api.integration.get)).toHaveBeenCalledWith({ integrationID: 'github-copilot' })
})

it('returns 404 when the integration is gone', async () => {
vi.useFakeTimers()
try {
Expand Down
16 changes: 8 additions & 8 deletions backend/src/routes/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
OAuthCallbackRequestSchema,
} from '../../../shared/src/schemas/auth'
import type { OpenCodeClient } from '../services/opencode/client'
import { runWhenIntegrationReady } from '../services/opencode/integration-ready'
import { awaitOpenCodePlugins } from '@opencode-manager/shared/opencode'
import { handleOpenCodeError } from '../utils/route-helpers'

export function createOAuthRoutes(openCodeClient: OpenCodeClient) {
const app = new Hono()

app.get('/auth-methods', async (c) => {
try {
await awaitOpenCodePlugins(openCodeClient.api)
const integrations = await openCodeClient.api.integration.list()
const providers = Object.fromEntries(
integrations.data.map((integration) => [integration.id, integration.methods]),
Expand All @@ -29,13 +30,12 @@ export function createOAuthRoutes(openCodeClient: OpenCodeClient) {
const validated = OAuthAuthorizeRequestSchema.parse(body)

const integrationID = c.req.param('id')
const attempt = await runWhenIntegrationReady(openCodeClient.api, { integrationID }, () =>
openCodeClient.api.integration.oauth.connect({
integrationID,
methodID: validated.methodID,
answer: validated.answer,
}),
)
await awaitOpenCodePlugins(openCodeClient.api)
const attempt = await openCodeClient.api.integration.oauth.connect({
integrationID,
methodID: validated.methodID,
answer: validated.answer,
})

return c.json({
attemptID: attempt.data.attemptID,
Expand Down
57 changes: 17 additions & 40 deletions backend/src/routes/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,22 +277,6 @@ describe('providers routes', () => {
expect(await readJson(res, CredentialStatusResponseSchema)).toEqual({ hasCredentials: false })
})

it('retries once after a loading integration becomes available', async () => {
const client = createStubOpenCodeClient()
const get = vi.mocked(client.api.integration.get)
get.mockRejectedValueOnce(taggedError('IntegrationNotFoundError', 'Integration not found: anthropic'))
get.mockResolvedValue({
location: LOCATION,
data: integrationFixture({ connections: [credentialConnection('cred_1')] }),
})

const res = await createCredentialApp(client).request('/providers/anthropic/credentials/status')

expect(res.status).toBe(200)
expect(await readJson(res, CredentialStatusResponseSchema)).toEqual({ hasCredentials: true })
expect(get).toHaveBeenCalledTimes(3)
})

it('returns 404 for a missing integration', async () => {
vi.useFakeTimers()
try {
Expand Down Expand Up @@ -363,17 +347,29 @@ describe('providers routes', () => {
expect(vi.mocked(client.api.integration.connect.key)).not.toHaveBeenCalled()
})

it('retries once after a loading integration becomes available', async () => {
it('waits for plugin activation before connecting the key', async () => {
const client = createStubOpenCodeClient()
const providerList = vi.mocked(client.api.provider.list)
const connectKey = vi.mocked(client.api.integration.connect.key)
connectKey.mockRejectedValueOnce(taggedError('IntegrationNotFoundError', 'Integration not found: anthropic'))

const res = await connectRequest(createCredentialApp(client))

expect(res.status).toBe(200)
expect(await readJson(res, SuccessResponseSchema)).toEqual({ success: true })
expect(connectKey).toHaveBeenCalledTimes(2)
expect(vi.mocked(client.api.integration.get)).toHaveBeenCalledWith({ integrationID: 'anthropic' })
expect(connectKey).toHaveBeenCalledTimes(1)
expect(providerList).toHaveBeenCalledBefore(connectKey)
})

it('returns the plugin-activation failure without connecting the key', async () => {
const client = createStubOpenCodeClient()
vi.mocked(client.api.provider.list).mockRejectedValueOnce(
taggedError('ServiceUnavailableError', 'Plugins are still loading'),
)

const res = await connectRequest(createCredentialApp(client))

expect(res.status).toBe(502)
expect(await readJson(res, ErrorResponseSchema)).toEqual({ error: 'Plugins are still loading' })
expect(vi.mocked(client.api.integration.connect.key)).not.toHaveBeenCalled()
})

it('returns 404 for a missing integration', async () => {
Expand Down Expand Up @@ -448,23 +444,6 @@ describe('providers routes', () => {
expect(vi.mocked(client.api.credential.remove)).not.toHaveBeenCalled()
})

it('retries once after a loading integration becomes available', async () => {
const client = createStubOpenCodeClient()
const get = vi.mocked(client.api.integration.get)
get.mockRejectedValueOnce(taggedError('IntegrationNotFoundError', 'Integration not found: anthropic'))
get.mockResolvedValue({
location: LOCATION,
data: integrationFixture({ connections: [credentialConnection('cred_1')] }),
})

const res = await createCredentialApp(client).request('/providers/anthropic/credentials', { method: 'DELETE' })

expect(res.status).toBe(200)
expect(await readJson(res, SuccessResponseSchema)).toEqual({ success: true })
expect(vi.mocked(client.api.credential.remove)).toHaveBeenCalledWith({ credentialID: 'cred_1' })
expect(get).toHaveBeenCalledTimes(3)
})

it('returns 404 for a missing integration', async () => {
vi.useFakeTimers()
try {
Expand Down Expand Up @@ -534,8 +513,6 @@ describe.skipIf(!openCodeBinary)('providers routes against a real OpenCode 2 ser
const app = new Hono()
app.route('/providers', createProvidersRoutes(client))

await client.api.integration.list()

const rejected = await app.request('/providers/azure/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand Down
18 changes: 8 additions & 10 deletions backend/src/routes/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { logger } from '../utils/logger'
import { handleOpenCodeError } from '../utils/route-helpers'
import {
addRecentModel,
awaitOpenCodePlugins,
removeRecentModel,
setModelVariant,
toggleFavoriteModel,
type IntegrationInfo,
} from '@opencode-manager/shared/opencode'
import type { OpenCodeClient } from '../services/opencode/client'
import { runWhenIntegrationReady } from '../services/opencode/integration-ready'

type IntegrationConnectKeyInput = NonNullable<Parameters<OpenCodeClient['api']['integration']['connect']['key']>[0]>
import {
Expand Down Expand Up @@ -80,6 +80,7 @@ export function createProvidersRoutes(openCodeClient: OpenCodeClient) {

app.get('/credentials', async (c) => {
try {
await awaitOpenCodePlugins(openCodeClient.api)
const integrations = await openCodeClient.api.integration.list()
const providers = integrations.data
.filter((integration) => credentialConnections(integration).length > 0)
Expand All @@ -93,9 +94,8 @@ export function createProvidersRoutes(openCodeClient: OpenCodeClient) {
app.get('/:id/credentials/status', async (c) => {
try {
const integrationID = c.req.param('id')
const integration = await runWhenIntegrationReady(openCodeClient.api, { integrationID }, () =>
openCodeClient.api.integration.get({ integrationID }),
)
await awaitOpenCodePlugins(openCodeClient.api)
const integration = await openCodeClient.api.integration.get({ integrationID })
return c.json({ hasCredentials: credentialConnections(integration.data).length > 0 })
} catch (error) {
return handleOpenCodeError(c, error, 'Failed to check credential status')
Expand All @@ -113,9 +113,8 @@ export function createProvidersRoutes(openCodeClient: OpenCodeClient) {
key: validated.apiKey,
answer: validated.answer,
}
await runWhenIntegrationReady(openCodeClient.api, { integrationID }, () =>
openCodeClient.api.integration.connect.key(connectInput),
)
await awaitOpenCodePlugins(openCodeClient.api)
await openCodeClient.api.integration.connect.key(connectInput)
Comment on lines +116 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status code mapping

A plugin-activation timeout (ServiceUnavailableError) goes through handleOpenCodeError and comes out as a 502. The new test asserts this. The condition is temporary and safe to retry, so a 503 would be more accurate and would let clients retry. This is minor and only matters if a client treats 502 and 503 differently.


return c.json({ success: true })
} catch (error) {
Expand All @@ -129,9 +128,8 @@ export function createProvidersRoutes(openCodeClient: OpenCodeClient) {
app.delete('/:id/credentials', async (c) => {
try {
const integrationID = c.req.param('id')
const integration = await runWhenIntegrationReady(openCodeClient.api, { integrationID }, () =>
openCodeClient.api.integration.get({ integrationID }),
)
await awaitOpenCodePlugins(openCodeClient.api)
const integration = await openCodeClient.api.integration.get({ integrationID })

for (const connection of credentialConnections(integration.data)) {
await openCodeClient.api.credential.remove({ credentialID: connection.id })
Expand Down
45 changes: 0 additions & 45 deletions backend/src/services/opencode/integration-ready.ts

This file was deleted.

4 changes: 4 additions & 0 deletions backend/test/helpers/stub-opencode-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export function createStubOpenCodeApi(overrides: OpenCodeApiOverrides = {}): Ope
connect: vi.fn(async () => undefined),
disconnect: vi.fn(async () => undefined),
})
applyNamespace(api.provider, {
list: vi.fn(async () => ({ location: { directory: '/tmp/repo' }, data: [] })),
})
applyNamespace(api.integration, {
list: vi.fn(async () => ({ location: { directory: '/tmp/repo' }, data: [] })),
get: vi.fn(async () => ({
Expand Down Expand Up @@ -106,6 +109,7 @@ export function createStubOpenCodeApi(overrides: OpenCodeApiOverrides = {}): Ope
applyNamespace(api.location, overrides.location)
applyNamespace(api.worktree, overrides.worktree)
applyNamespace(api.mcp, overrides.mcp)
applyNamespace(api.provider, overrides.provider)
applyNamespace(api.integration, overrides.integration)
applyNamespace(api.credential, overrides.credential)
applyNamespace(api.skill, overrides.skill)
Expand Down
98 changes: 0 additions & 98 deletions backend/test/services/opencode/integration-ready.test.ts

This file was deleted.

Loading
Loading