Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
568e675
feat(opencode): add custom block integration
Danigm-dev Mar 25, 2026
3146562
feat(opencode): add optional runtime overlay
Danigm-dev Mar 25, 2026
7014fa4
fix(opencode): harden external runtime contract
Danigm-dev Mar 25, 2026
33b834b
docs(opencode): add deployment checklists
Danigm-dev Mar 25, 2026
cacd46a
test(opencode): cover route contracts
Danigm-dev Mar 25, 2026
6160d1c
fix(opencode): address review feedback
Danigm-dev Mar 25, 2026
8ec0c5a
fix(opencode): harden runtime defaults
Danigm-dev Mar 25, 2026
d59f7a1
fix(opencode): narrow stale session retries
Danigm-dev Mar 25, 2026
1e174f7
fix(opencode): avoid redundant resolution and url leaks
Danigm-dev Mar 25, 2026
35fac8d
fix(opencode): clean up low severity review notes
Danigm-dev Mar 25, 2026
35949bb
fix(opencode): harden root path and retry errors
Danigm-dev Mar 25, 2026
3458868
refactor(opencode): keep base url helper private
Danigm-dev Mar 25, 2026
a27de0d
fix(editor): avoid stale open-change fetch gating
Danigm-dev Mar 25, 2026
a8fb073
fix(opencode): persist fresh retry sessions
Danigm-dev Mar 25, 2026
2bb744a
fix(opencode): tighten retry and entrypoint guards
Danigm-dev Mar 25, 2026
5ab2b5f
fix(editor): stabilize async option refetching
Danigm-dev Mar 25, 2026
d246b50
docs(opencode): add branch status summary
Danigm-dev Mar 25, 2026
f1156e9
docs(opencode): clarify branch scope and overlap
Danigm-dev Mar 25, 2026
cb57bad
docs(opencode): remove internal branch note from PR
Danigm-dev Mar 26, 2026
e4c40ae
fix opencode review follow-ups
Danigm-dev Mar 26, 2026
d543a9d
fix opencode async selector refresh
Danigm-dev Mar 26, 2026
59be14f
fix opencode async selector force default
Danigm-dev Mar 26, 2026
0ca5e24
clean up opencode async selector refs
Danigm-dev Mar 26, 2026
d77d875
guard opencode async selector stale fetches
Danigm-dev Mar 26, 2026
02bcfa0
Merge branch 'staging' into feat/opencode-optional-runtime
Danigm-dev Mar 26, 2026
e77557c
fix opencode selector stale reload and lint
Danigm-dev Mar 26, 2026
4ab5c68
fix opencode docker script permissions
Danigm-dev Mar 26, 2026
314e410
fix opencode client reuse and selector refetch
Danigm-dev Mar 26, 2026
780311e
fix opencode connectivity error classification
Danigm-dev Mar 26, 2026
7fc5621
fix opencode client refresh and selector loading
Danigm-dev Mar 26, 2026
2b76ffa
fix opencode client cleanup and prompt schema naming
Danigm-dev Mar 26, 2026
13f4b56
fix opencode client key helper reuse
Danigm-dev Mar 26, 2026
9bb48d0
Merge branch 'staging' into feat/opencode-optional-runtime
Danigm-dev Apr 1, 2026
76f36ea
Merge branch 'staging' into feat/opencode-optional-runtime
Danigm-dev Apr 7, 2026
c0cd00a
fix: avoid forced async selector refetch on open
Danigm-dev Apr 7, 2026
f791d66
fix: align async selector fetch signatures
Danigm-dev Apr 7, 2026
98cae4c
fix: pass subblock id to option hydration
Danigm-dev Apr 7, 2026
ae3b633
Merge branch 'staging' into feat/opencode-optional-runtime
Danigm-dev Apr 8, 2026
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
Prev Previous commit
Next Next commit
fix(opencode): avoid redundant resolution and url leaks
  • Loading branch information
Danigm-dev committed Mar 25, 2026
commit 1e174f75a9e04344b21ad5ed42deb114f6e4e465
24 changes: 22 additions & 2 deletions apps/sim/app/api/tools/opencode/prompt/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,19 @@ describe('POST /api/tools/opencode/prompt', () => {
expect(mockBuildOpenCodeSessionMemoryKey).toHaveBeenCalledWith('wf-1', 'user:user-123')
expect(mockGetStoredOpenCodeSession).toHaveBeenCalledWith('ws-1', 'memory-key')
expect(mockBuildOpenCodeSessionTitle).toHaveBeenCalledWith('repo-a', 'user:user-123')
expect(mockCreateOpenCodeSession).toHaveBeenCalledWith('repo-a', 'session-title')
expect(mockCreateOpenCodeSession).toHaveBeenCalledWith(
expect.objectContaining({
id: 'repo-a',
directory: '/app/repos/repo-a',
}),
'session-title'
)
expect(mockPromptOpenCodeSession).toHaveBeenCalledWith({
repository: 'repo-a',
repositoryOption: expect.objectContaining({
id: 'repo-a',
directory: '/app/repos/repo-a',
}),
sessionId: 'session-1',
prompt: 'explain the change',
systemPrompt: 'system prompt',
Expand Down Expand Up @@ -202,6 +212,10 @@ describe('POST /api/tools/opencode/prompt', () => {
expect(mockCreateOpenCodeSession).not.toHaveBeenCalled()
expect(mockPromptOpenCodeSession).toHaveBeenCalledWith({
repository: 'repo-a',
repositoryOption: expect.objectContaining({
id: 'repo-a',
directory: '/app/repos/repo-a',
}),
sessionId: 'stored-session',
prompt: 'continue',
systemPrompt: undefined,
Expand Down Expand Up @@ -254,7 +268,13 @@ describe('POST /api/tools/opencode/prompt', () => {
expect(mockShouldRetryWithFreshOpenCodeSession).toHaveBeenCalledWith(
expect.objectContaining({ message: 'session not found' })
)
expect(mockCreateOpenCodeSession).toHaveBeenCalledWith('repo-a', 'session-title')
expect(mockCreateOpenCodeSession).toHaveBeenCalledWith(
expect.objectContaining({
id: 'repo-a',
directory: '/app/repos/repo-a',
}),
'session-title'
)
expect(mockStoreOpenCodeSession).toHaveBeenCalledWith(
'ws-1',
'memory-key',
Expand Down
28 changes: 22 additions & 6 deletions apps/sim/app/api/tools/opencode/prompt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,15 @@ function buildErrorResponse(
async function executePrompt(
params: z.infer<typeof OpenCodePromptSchema>,
repository: string,
repositoryOption: Awaited<ReturnType<typeof resolveOpenCodeRepositoryOption>>,
threadId: string,
prompt: string,
providerId: string,
modelId: string
) {
return promptOpenCodeSession({
repository,
repositoryOption,
sessionId: threadId,
prompt,
systemPrompt: params.systemPrompt?.trim() || undefined,
Expand Down Expand Up @@ -146,18 +148,28 @@ export async function POST(request: NextRequest) {
const newThread = coerceBoolean(body.newThread)
const storedThread = newThread ? null : await getStoredOpenCodeSession(workspaceId, memoryKey)
let threadId =
storedThread && storedThread.repository === repositoryId ? storedThread.sessionId : undefined
storedThread && storedThread.repository === repositoryId
? storedThread.sessionId
: undefined

if (!threadId) {
const session = await createOpenCodeSession(
repositoryId,
repositoryOption,
buildOpenCodeSessionTitle(repositoryId, sessionOwnerKey)
)
threadId = session.id
}

try {
const result = await executePrompt(body, repositoryId, threadId, prompt, providerId, modelId)
const result = await executePrompt(
body,
repositoryId,
repositoryOption,
threadId,
prompt,
providerId,
modelId
)

await storeOpenCodeSession(workspaceId, memoryKey, {
sessionId: result.threadId,
Expand All @@ -179,12 +191,13 @@ export async function POST(request: NextRequest) {
if (threadId && !newThread && shouldRetryWithFreshOpenCodeSession(error)) {
try {
const freshSession = await createOpenCodeSession(
repositoryId,
repositoryOption,
buildOpenCodeSessionTitle(repositoryId, sessionOwnerKey)
)
const result = await executePrompt(
body,
repositoryId,
repositoryOption,
freshSession.id,
prompt,
providerId,
Expand Down Expand Up @@ -214,13 +227,16 @@ export async function POST(request: NextRequest) {
)

const errorMessage =
retryError instanceof Error ? retryError.message : 'OpenCode prompt retry failed'
retryError instanceof Error
? retryError.message
: 'OpenCode prompt retry failed'
return buildErrorResponse(threadId, '', undefined, errorMessage)
}
Comment thread
Danigm-dev marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.

await logOpenCodeFailure('Failed to execute OpenCode prompt', error)
const errorMessage = error instanceof Error ? error.message : 'OpenCode prompt failed'
const errorMessage =
error instanceof Error ? error.message : 'OpenCode prompt failed'
return buildErrorResponse(threadId || '', '', undefined, errorMessage)
}
} catch (error) {
Expand Down
22 changes: 22 additions & 0 deletions apps/sim/lib/opencode/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @vitest-environment node
*/

import { describe, expect, it } from 'vitest'
import { getOpenCodeRouteError } from '@/lib/opencode/errors'

describe('getOpenCodeRouteError', () => {
it('does not leak the internal OpenCode base URL in connectivity errors', () => {
const error = getOpenCodeRouteError(
new Error('fetch failed for http://opencode:4096/session'),
'repositories',
)

expect(error).toEqual({
status: 503,
message:
'OpenCode server is unreachable. Check OPENCODE_BASE_URL and the runtime network configuration.',
})
expect(error.message).not.toContain('http://opencode:4096')
})
})
5 changes: 2 additions & 3 deletions apps/sim/lib/opencode/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { getOpenCodeBaseUrl } from '@/lib/opencode/client'

export interface OpenCodeRouteError {
status: number
message: string
Expand Down Expand Up @@ -72,7 +70,8 @@ export function getOpenCodeRouteError(error: unknown, resourceName: string): Ope
) {
return {
status: 503,
message: `OpenCode server is unreachable at ${getOpenCodeBaseUrl()}.`,
message:
'OpenCode server is unreachable. Check OPENCODE_BASE_URL and the runtime network configuration.',
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

Expand Down
80 changes: 78 additions & 2 deletions apps/sim/lib/opencode/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

import { describe, expect, it, vi } from 'vitest'

const { mockCreateOpenCodeClient } = vi.hoisted(() => ({
mockCreateOpenCodeClient: vi.fn(),
}))

vi.mock('@sim/db', () => ({
db: {},
}))
Expand Down Expand Up @@ -31,10 +35,13 @@ vi.mock('drizzle-orm', () => ({
}))

vi.mock('@/lib/opencode/client', () => ({
createOpenCodeClient: vi.fn(),
createOpenCodeClient: mockCreateOpenCodeClient,
}))

import { shouldRetryWithFreshOpenCodeSession } from '@/lib/opencode/service'
import {
promptOpenCodeSession,
shouldRetryWithFreshOpenCodeSession,
} from '@/lib/opencode/service'

describe('shouldRetryWithFreshOpenCodeSession', () => {
it('returns true for stale-session errors', () => {
Expand All @@ -50,3 +57,72 @@ describe('shouldRetryWithFreshOpenCodeSession', () => {
expect(shouldRetryWithFreshOpenCodeSession('provider does not exist')).toBe(false)
})
})

describe('promptOpenCodeSession', () => {
it('reuses the provided repository option without resolving repositories again', async () => {
const mockSessionCreate = vi.fn().mockResolvedValue({
data: { id: 'session-1' },
})
const mockSessionPrompt = vi.fn().mockResolvedValue({
data: {
info: {
sessionID: 'session-1',
cost: 0.75,
providerID: 'provider-a',
modelID: 'model-a',
},
parts: [{ type: 'text', text: 'OpenCode result' }],
},
})

mockCreateOpenCodeClient.mockReturnValue({
project: {
list: vi.fn(),
},
session: {
create: mockSessionCreate,
prompt: mockSessionPrompt,
},
})

const result = await promptOpenCodeSession({
repository: 'repo-a',
repositoryOption: {
id: 'repo-a',
label: 'repo-a',
directory: '/app/repos/repo-a',
projectId: 'project-1',
},
prompt: 'Explain the change',
providerId: 'provider-a',
modelId: 'model-a',
title: 'session-title',
})

expect(mockSessionCreate).toHaveBeenCalledWith({
query: { directory: '/app/repos/repo-a' },
body: { title: 'session-title' },
throwOnError: true,
})
expect(mockSessionPrompt).toHaveBeenCalledWith({
path: { id: 'session-1' },
query: { directory: '/app/repos/repo-a' },
body: {
parts: [{ type: 'text', text: 'Explain the change' }],
model: {
providerID: 'provider-a',
modelID: 'model-a',
},
},
throwOnError: true,
})
expect(result).toEqual({
content: 'OpenCode result',
threadId: 'session-1',
cost: 0.75,
providerId: 'provider-a',
modelId: 'model-a',
assistantError: undefined,
})
})
})
35 changes: 29 additions & 6 deletions apps/sim/lib/opencode/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface OpenCodePromptRequest {
prompt: string
providerId: string
modelId: string
repositoryOption?: OpenCodeRepositoryOption
systemPrompt?: string
agent?: string
sessionId?: string
Expand Down Expand Up @@ -295,6 +296,16 @@ export async function resolveOpenCodeRepositoryOption(
return repositoryOption
}

async function getOpenCodeRepositoryOption(
repository: string | OpenCodeRepositoryOption
): Promise<OpenCodeRepositoryOption> {
if (typeof repository === 'string') {
return resolveOpenCodeRepositoryOption(repository)
}

return repository
}

export async function listOpenCodeProviders(
repository?: string
): Promise<OpenCodeProviderOption[]> {
Expand Down Expand Up @@ -352,11 +363,11 @@ export async function listOpenCodeAgents(repository?: string): Promise<OpenCodeA
}

export async function createOpenCodeSession(
repository: string,
repository: string | OpenCodeRepositoryOption,
title?: string
): Promise<{ id: string }> {
const client = createOpenCodeClient()
const repositoryOption = await resolveOpenCodeRepositoryOption(repository)
const repositoryOption = await getOpenCodeRepositoryOption(repository)
const sessionResult = await client.session.create({
query: { directory: repositoryOption.directory },
body: title ? { title } : undefined,
Expand All @@ -370,10 +381,13 @@ export async function promptOpenCodeSession(
request: OpenCodePromptRequest
): Promise<OpenCodePromptResult> {
const client = createOpenCodeClient()
const repositoryOption = await resolveOpenCodeRepositoryOption(request.repository)
const repositoryOption =
request.repositoryOption ||
(await resolveOpenCodeRepositoryOption(request.repository))
const directory = repositoryOption.directory
const sessionId =
request.sessionId || (await createOpenCodeSession(request.repository, request.title)).id
request.sessionId ||
(await createOpenCodeSession(repositoryOption, request.title)).id

const response = await client.session.prompt({
path: { id: sessionId },
Expand Down Expand Up @@ -433,7 +447,13 @@ export async function getStoredOpenCodeSession(
const result = await db
.select({ data: memory.data })
.from(memory)
.where(and(eq(memory.workspaceId, workspaceId), eq(memory.key, key), isNull(memory.deletedAt)))
.where(
and(
eq(memory.workspaceId, workspaceId),
eq(memory.key, key),
isNull(memory.deletedAt)
)
)
.limit(1)

if (result.length === 0) {
Expand Down Expand Up @@ -520,6 +540,9 @@ export function shouldRetryWithFreshOpenCodeSession(error: unknown): boolean {
return normalized.includes('404') && normalized.includes('session')
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

export async function logOpenCodeFailure(message: string, error: unknown): Promise<void> {
export async function logOpenCodeFailure(
message: string,
error: unknown
): Promise<void> {
logger.error(message, { error })
}