diff --git a/backend/package.json b/backend/package.json index 79fb4bd82..900ba0324 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,6 @@ "archiver": "^7.0.1", "better-auth": "^1.7.12", "croner": "^10.0.1", - "eventsource": "^4.1.0", "hono": "^4.13.13", "jsonc-parser": "^3.3.1", "web-push": "^3.6.7", @@ -35,7 +34,6 @@ "@types/archiver": "^7.0.0", "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", - "@types/eventsource": "^3.0.0", "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", diff --git a/backend/src/index.ts b/backend/src/index.ts index 019a1f3c5..2768b5ecb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -84,7 +84,7 @@ import { RepoWorkspaceService } from './services/repo-workspace' import { createGitService } from './services/git/GitService' import { ScheduleWorktreeManager } from './services/schedule-worktree' import { migrateGlobalSkills } from './services/skills' -import { installAssistantWorkspace } from './services/assistant-mode' +import { installAssistantWorkspace, convertAssistantOpenCodeConfigToV2 } from './services/assistant-mode' import { detectSandboxCapability } from './services/sandbox/capability' import { SandboxRuntimeService, stopSandboxesOnShutdown } from './services/sandbox/runtime' import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import' @@ -359,12 +359,18 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { }) } -sseAggregator.setPendingActionsFetcher(openCodeClient) -sseAggregator.setPasswordResolver(() => new SettingsService(db).getOpenCodeServerPassword()) +sseAggregator.setOpenCodeClient(openCodeClient) +sseAggregator.setResyncGate(() => opencodeServerManager.isLifecycleInitialized()) +opencodeServerManager.onLifecycleInitialized(() => sseAggregator.releasePendingResync()) sseAggregator.onServerConnected((serverId) => { void sessionPermissionModeService.acceptPendingRequestsForActiveSessions(serverId).catch((err) => { logger.error(`Failed to accept pending permission requests for server ${serverId}:`, err) }) + if (serverId === LOCAL_OPENCODE_SERVER_ID) { + void convertAssistantOpenCodeConfigToV2(openCodeClient.api).catch((err) => { + logger.error('Failed to convert the Assistant OpenCode config to v2:', err) + }) + } }) sseAggregator.start() diff --git a/backend/src/routes/opencode-config.ts b/backend/src/routes/opencode-config.ts index 027a633f5..90446ed9b 100644 --- a/backend/src/routes/opencode-config.ts +++ b/backend/src/routes/opencode-config.ts @@ -1,5 +1,4 @@ import { Hono, type Context } from 'hono' -import { z } from 'zod' import { UpdateOpenCodeConfigPatchRequestSchema, UpdateOpenCodeConfigRequestSchema, @@ -20,11 +19,15 @@ import { OpenCodeConfigRedactedValueError, OpenCodeConfigShadowedRemovalError, OpenCodeConfigSourceInvalidError, + OpenCodeConfigValidationError, readOpenCodeConfigFile, + readOpenCodeConfigSnapshot, withOpenCodeConfigLock, } from '../services/opencode-config-file' +import { readManagedOpenCodeConfig } from '../services/opencode-config-effective' import { applyOpenCodeConfigUpdate, toOpenCodeConfigApplyResponse } from '../services/opencode-config-apply' import { redactOpenCodeConfigContent, redactOpenCodeConfigFile } from '../services/opencode-config-redact' +import { opencodeServerManager } from '../services/opencode-single-server' import { logger } from '../utils/logger' interface OpenCodeConfigRoutesOptions { @@ -53,6 +56,16 @@ function mergeMcpServerStatus(views: McpServerView[], status: McpStatusMap) { }) } +function toClientReadErrorResponse(c: Context, error: unknown, message: string) { + if (error instanceof ClientError) { + if (error.reason === 'Transport') { + return c.json({ error: 'OpenCode server unavailable' }, 503) + } + return c.json({ error: message }, 502) + } + return c.json({ error: message }, 500) +} + export function createOpenCodeConfigRoutes( settingsService: SettingsService, openCodeClient: OpenCodeClient, @@ -90,30 +103,40 @@ export function createOpenCodeConfigRoutes( }) } catch (error) { logger.error('Failed to get effective OpenCode config:', error) - if (error instanceof ClientError) { - if (error.reason === 'Transport') { - return c.json({ error: 'OpenCode server unavailable' }, 503) - } - return c.json({ error: 'Failed to get effective OpenCode config' }, 502) - } - return c.json({ error: 'Failed to get effective OpenCode config' }, 500) + return toClientReadErrorResponse(c, error, 'Failed to get effective OpenCode config') + } + }) + + app.get('/managed', async (c) => { + try { + const snapshot = await withOpenCodeConfigLock(readOpenCodeConfigSnapshot) + const info = await readManagedOpenCodeConfig(openCodeClient.api, snapshot.sources) + return c.json({ + info: options.redactSecrets + ? redactOpenCodeConfigContent(info as Record).content + : info, + }) + } catch (error) { + logger.error('Failed to get managed OpenCode config:', error) + return toClientReadErrorResponse(c, error, 'Failed to get managed OpenCode config') } }) app.get('/mcp', async (c) => { try { - const [config, status] = await Promise.all([ - withOpenCodeConfigLock(readOpenCodeConfigFile), + const snapshot = await withOpenCodeConfigLock(readOpenCodeConfigSnapshot) + const [info, status] = await Promise.all([ + readManagedOpenCodeConfig(openCodeClient.api, snapshot.sources), readMcpStatus(openCodeClient), ]) - const views = mcpServerViewsFromConfig(config?.content.mcp) + const views = mcpServerViewsFromConfig(info.mcp) return c.json({ - revision: config?.revision ?? null, + revision: snapshot.revision, servers: mergeMcpServerStatus(views, status), }) } catch (error) { logger.error('Failed to get OpenCode MCP servers:', error) - return c.json({ error: 'Failed to get OpenCode MCP servers' }, 500) + return toClientReadErrorResponse(c, error, 'Failed to get OpenCode MCP servers') } }) @@ -152,6 +175,12 @@ export function createOpenCodeConfigRoutes( return c.json({ error: 'Invalid config data', details: parsed.error.issues }, 400) } + if (opencodeServerManager.isRestartPending()) { + return c.json({ + error: 'OpenCode has not loaded the saved configuration yet. Restart OpenCode before editing entries.', + }, 409) + } + return applyUpdate(c, { content: parsed.data.patch, source: parsed.data.source, @@ -198,12 +227,12 @@ async function applyUpdate( if (error instanceof OpenCodeConfigRedactedValueError) { return c.json({ error: error.message, paths: error.paths }, 400) } - if (error instanceof z.ZodError) { - return c.json({ error: 'Invalid config data', details: error.issues }, 400) + if (error instanceof OpenCodeConfigValidationError) { + return c.json({ error: 'Invalid config data', validationIssues: error.issues }, 400) } if (error instanceof SyntaxError) { return c.json({ error: 'Invalid config data', details: error.message }, 400) } - return c.json({ error: 'Failed to update OpenCode config' }, 500) + return toClientReadErrorResponse(c, error, 'Failed to update OpenCode config') } } diff --git a/backend/src/routes/opencode-proxy.ts b/backend/src/routes/opencode-proxy.ts index d3be10b56..02dd2c009 100644 --- a/backend/src/routes/opencode-proxy.ts +++ b/backend/src/routes/opencode-proxy.ts @@ -16,6 +16,7 @@ import { getRepoById } from '../db/queries' import { getTrustedOrigins, getWorkspacePath } from '@opencode-manager/shared/config/env' import { isPathWithinRoot } from '../utils/fs-safe' import { buildProxyResponseHeaders, filterProxyHeaders } from '../utils/proxy-headers' +import { forwardProxyRequest } from '../utils/proxy-forward' import { connectUpstreamWebSocket, createBridgedSocketEvents, @@ -192,20 +193,12 @@ export function createOpenCodeProxyRoutes(db: Database, settingsService: Setting const upstreamUrl = `${getOpenCodeUpstreamBaseUrl()}${pathSuffix}${url.search}` try { - const upstreamResponse = await fetch(upstreamUrl, { + return await forwardProxyRequest({ + url: upstreamUrl, method: c.req.method, headers, body: requestBody, - redirect: 'manual', - duplex: 'half', - }) - - const responseHeaders = buildProxyResponseHeaders(upstreamResponse.headers) - - return new Response(upstreamResponse.body, { - status: upstreamResponse.status, - statusText: upstreamResponse.statusText, - headers: responseHeaders, + buildResponseHeaders: buildProxyResponseHeaders, }) } catch { return c.json({ error: 'Proxy request failed' }, 502) diff --git a/backend/src/routes/opencode-server-proxy.ts b/backend/src/routes/opencode-server-proxy.ts index 38c68b310..67a9d124d 100644 --- a/backend/src/routes/opencode-server-proxy.ts +++ b/backend/src/routes/opencode-server-proxy.ts @@ -6,7 +6,8 @@ import { type OpenCodeServerConnection, type OpenCodeServerRegistry, } from '../services/opencode-server-registry' -import { filterProxyHeaders, NULL_BODY_STATUSES } from '../utils/proxy-headers' +import { filterProxyHeaders } from '../utils/proxy-headers' +import { forwardProxyRequest, ProxyHeadersTimeoutError } from '../utils/proxy-forward' import { logger } from '../utils/logger' const MOUNT_PREFIX = '/api/opencode/servers/' @@ -117,58 +118,28 @@ export function createOpenCodeServerProxyRoutes( headers.Authorization = connection.authorization } - const hasBody = c.req.method !== 'GET' && c.req.method !== 'HEAD' - - let headersTimeoutTimer: ReturnType | null = null - let headersTimedOut = false - let upstreamSignal = c.req.raw.signal - if (!hasBody) { - const timeoutController = new AbortController() - headersTimeoutTimer = setTimeout(() => { - headersTimedOut = true - timeoutController.abort() - }, upstreamHeadersTimeoutMs) - upstreamSignal = AbortSignal.any([c.req.raw.signal, timeoutController.signal]) - } + const isBodylessMethod = c.req.method === 'GET' || c.req.method === 'HEAD' try { - const upstreamResponse = await fetchFn(upstream.url.toString(), { + return await forwardProxyRequest({ + url: upstream.url.toString(), method: c.req.method, headers, - body: hasBody ? c.req.raw.body : undefined, - redirect: 'manual', - signal: upstreamSignal, - duplex: 'half', - }) - - if (headersTimeoutTimer !== null) { - clearTimeout(headersTimeoutTimer) - headersTimeoutTimer = null - } - - const responseHeaders = new Headers( - filterProxyHeaders(upstreamResponse.headers, RESPONSE_EXCLUDED_HEADERS), - ) - const responseBody = NULL_BODY_STATUSES.has(upstreamResponse.status) - ? null - : upstreamResponse.body - - return new Response(responseBody, { - status: upstreamResponse.status, - statusText: upstreamResponse.statusText, - headers: responseHeaders, + body: isBodylessMethod ? null : c.req.raw.body, + signal: c.req.raw.signal, + headersTimeoutMs: isBodylessMethod ? upstreamHeadersTimeoutMs : undefined, + fetchFn, + buildResponseHeaders: (upstreamHeaders) => new Headers( + filterProxyHeaders(upstreamHeaders, RESPONSE_EXCLUDED_HEADERS), + ), }) } catch (error) { - if (headersTimedOut) { + if (error instanceof ProxyHeadersTimeoutError) { logger.warn(`OpenCode server proxy timed out waiting for headers: ${c.req.method} ${upstream.url.host}${upstreamPath.path}`, error) return c.json({ error: 'Remote OpenCode server timed out' }, 504) } logger.warn(`OpenCode server proxy request failed: ${c.req.method} ${upstream.url.host}${upstreamPath.path}`, error) return c.json({ error: 'Remote OpenCode server unreachable' }, 502) - } finally { - if (headersTimeoutTimer !== null) { - clearTimeout(headersTimeoutTimer) - } } }) diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index b90916768..1399aeac2 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -27,7 +27,7 @@ import type { SecretsService } from '../services/secrets' import type { RepoWorkspaceService } from '../services/repo-workspace' import { createGitService } from '../services/git/GitService' import { ScheduleService } from '../services/schedules' -import { ensureAssistantMode, getAssistantModeStatus } from '../services/assistant-mode' +import { ensureAssistantMode, getAssistantModeStatus, convertAssistantOpenCodeConfigToV2 } from '../services/assistant-mode' import path from 'path' const DeleteWorkspaceRequestSchema = z.object({ @@ -614,6 +614,7 @@ app.get('/', async (c) => { const options = AssistantModeInitRequestSchema.parse(body) const status = await ensureAssistantMode(repo, options) + await convertAssistantOpenCodeConfigToV2(openCodeClient.api) return c.json(status) } catch (error: unknown) { logger.error('Failed to initialize assistant mode:', error) diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index d6195daea..59d9b5f63 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -4,7 +4,6 @@ import type { Repo } from '@opencode-manager/shared/types' import type { AssistantModeStatus, AssistantModeInitRequest, - OpenCodeConfigInput, } from '@opencode-manager/shared/types' import { readFileContent, @@ -12,12 +11,18 @@ import { fileExists, ensureDirectoryExists, } from './file-operations' -import { ASSISTANT_NOTIFICATION_LIMITS, AssistantSettingsPatchSchema, OpenCodeConfigSchema, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY, MANAGER_TOOL_NAME } from '@opencode-manager/shared/schemas' +import { ASSISTANT_NOTIFICATION_LIMITS, AssistantSettingsPatchSchema, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY, MANAGER_TOOL_NAME } from '@opencode-manager/shared/schemas' import { MANAGER_COLOR_THEME_ID, OPENCODE_THEMES } from '@opencode-manager/shared/themes' -import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH, ASSISTANT_OPENCODE_DIR_NAME } from '@opencode-manager/shared/utils' +import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH, ASSISTANT_OPENCODE_DIR_NAME, isRecord, parseJsonc } from '@opencode-manager/shared/utils' import { getAssistantModePath, getReposPath } from '@opencode-manager/shared/config/env' +import type { OpenCodeAgentConfig, OpenCodeApi, OpenCodeConfigInfo } from '@opencode-manager/shared/opencode' +import { validateOpenCodeConfig } from '@opencode-manager/shared/opencode/config-validation' import type { Database } from 'bun:sqlite' import { ensureAssistantRepo } from '../db/queries' +import { applyOpenCodeConfigEdits, removeOpenCodeConfigProperty, V1_ENTITY_PATHS, type OpenCodeConfigPathOperation } from './opencode-config-file' +import { readConfigValueAtPath, restoreOpenCodeConfigReferences } from './opencode-config-references' +import { readOpenCodeConfigDocuments } from './opencode-config-effective' +import { logger } from '../utils/logger' const ASSISTANT_MODE_DIR = ASSISTANT_REPO_PATH @@ -784,9 +789,9 @@ Read the merged persisted global configuration and its source files. Returns \`4 ### PATCH /opencode-config -Change only the paths the user asked for. Send a nested \`patch\` object naming those paths and the values to set; every path you do not name is left untouched, and a \`null\` value removes a path. Only changed fields are patched into the preferred existing source: JSONC, then JSON. New installations use opencode.jsonc. Unchanged inherited values and comments are preserved. +Change only the paths the user asked for. Send a nested \`patch\` object naming those paths and the values to set; a path you do not name keeps its current value, and a \`null\` value removes a path. Naming a v2 entity (\`agents.\`, \`commands.\`, \`providers.\`, \`mcp.servers.\`, or \`permissions\`) also deletes its v1 counterpart (\`agent.\`, \`command.\`, \`provider.\`, \`mcp.\`, or \`permission\`) in the same edit. \`{env:…}\` and \`{file:…}\` references in fields whose value is sent back unchanged are kept. Only changed fields are patched into the preferred existing source: JSONC, then JSON. New installations use opencode.jsonc. Unchanged inherited values and comments are preserved. -Send \`expectedRevision\` from a read. A \`409\` means the source files changed: read again and reconcile rather than retrying stale content. +Send \`expectedRevision\` from a read. A \`409\` means either \`expectedRevision\` is stale or the v1 entry to remove exists only in a lower-priority config source (the \`OpenCodeConfigShadowedRemovalError\` case): read again and reconcile rather than retrying stale content. **Request Body:** \`\`\`ts @@ -814,35 +819,36 @@ Send \`expectedRevision\` from a read. A \`409\` means the source files changed: \`\`\` **Response:** -Returns the refreshed merged configuration and source files, with secrets redacted. A semantic change is applied automatically: the Manager reloads OpenCode without restarting the server, so agents, permissions, providers, models, and plugins take effect on the next message and running sessions keep running. An \`mcp\` change is applied the same way, and only the servers whose configuration changed reconnect. Comment-only changes do nothing. Saving never silently drops unsupported fields. +Returns the refreshed merged configuration and source files, with secrets redacted. A semantic change is applied automatically: the Manager reloads OpenCode without restarting the server, so agents, permissions, providers, models, and plugins take effect on the next message and running sessions keep running. An \`mcp\` change is applied the same way, and only the servers whose configuration changed reconnect. Comment-only changes do nothing. The response adds \`restartRequired: true\` only when the automatic reload failed, for example because the OpenCode server is unavailable. -Returns \`400\` for invalid configuration or a \`\` value (\`paths\` lists them), and \`409\` for a stale revision. +Returns \`400\` for invalid configuration or a \`\` value (\`paths\` lists them), and \`409\` when \`expectedRevision\` is stale or the v1 entry to remove exists only in a lower-priority config source. ### GET /opencode-config/mcp -List the configured MCP servers with their stored shape, enabled state, and live connection status. Use this to answer questions about MCP servers instead of reading the whole configuration. Header and environment values are never returned. +List the configured MCP servers with their enabled state and live connection status. Use this to answer questions about MCP servers instead of reading the whole configuration. Header and environment values are never returned. -\`type\` is \`local\` or \`remote\`, and \`command\` or \`url\` is included for the matching type. \`enabled\` reflects the stored flag. \`shape\` is \`servers\` for a native \`mcp.servers.\` entry or \`legacy\` for a flat \`mcp.\` entry, so you can address the entry by the path it actually uses. \`status\` is the live OpenCode status (\`connected\`, \`pending\`, \`disabled\`, \`failed\`, or \`needs_auth\`) when the server is reachable, and \`error\` carries the failure reason. +\`type\` is \`local\` or \`remote\`, and \`command\` or \`url\` is included for the matching type. \`enabled\` reflects the stored flag. Every entry comes from a native \`mcp.servers.\` entry, so address it by that path; saving \`mcp.servers.\` replaces a flat \`mcp.\` entry left by an older config. \`status\` is the live OpenCode status (\`connected\`, \`pending\`, \`disabled\`, \`failed\`, or \`needs_auth\`) when the server is reachable, and \`error\` carries the failure reason. **Response:** \`\`\`ts { - revision: string | null + revision: string servers: Array<{ name: string type: 'local' | 'remote' command?: string[] url?: string enabled: boolean - shape: 'servers' | 'legacy' status?: 'connected' | 'pending' | 'disabled' | 'failed' | 'needs_auth' error?: string }> } \`\`\` +GET /opencode-config/mcp returns \`503\` when the OpenCode server is unreachable. + When the response contains \`restartRequired: true\`, tell the user to restart the OpenCode server from Settings. Never attempt the restart yourself: it would terminate your own session. Only changes to how the OpenCode process is launched need a user restart from Settings: server environment variables, Git credentials and identity, sandbox enforcement, rotating the manager token, the server password, and installing or upgrading the OpenCode version. You cannot make these changes through this API. @@ -850,7 +856,7 @@ Only changes to how the OpenCode process is launched need a user restart from Se ## Safety - The settings PATCH endpoint rejects any attempt to modify credentials, API keys, or other sensitive settings; guide the user to the full UI for Git, TTS, and STT credentials -- PATCH /opencode-config patches only the paths you name, including \`plugin\`, \`mcp\`, and \`provider\` entries; change only the keys the user explicitly asked for and never add plugins, MCP servers, or provider credentials the user did not request +- PATCH /opencode-config patches only the paths you name, including \`plugins\`, \`mcp.servers\`, and \`providers\` entries; change only the keys the user explicitly asked for and never add plugins, MCP servers, or provider credentials the user did not request - GET /opencode-config redacts secrets; never reconstruct a secret you did not read, and never send a \`\` value back - The settings PATCH endpoint does NOT trigger an OpenCode reload or restart ` @@ -1127,18 +1133,25 @@ Fork a session, optionally before a specific message. Omit \`beforeMessageId\` t ` } -export function buildAssistantOpenCodeConfig(): OpenCodeConfigInput { - const config: OpenCodeConfigInput = { +export function buildAssistantOpenCodeConfig(): OpenCodeConfigInfo { + const permission = buildAssistantAgentPermission() + const config: OpenCodeConfigInfo = { default_agent: ASSISTANT_DEFAULT_AGENT_NAME, - permission: buildAssistantAgentPermission(), - agent: { + permissions: Object.entries(permission).map(([action, effect]) => ({ + action, + resource: '*', + effect, + })), + agents: { [ASSISTANT_DEFAULT_AGENT_NAME]: { mode: 'primary' }, }, } - const result = OpenCodeConfigSchema.safeParse(config) + const result = validateOpenCodeConfig(config) if (!result.success) { - throw new Error(`Generated OpenCode config is invalid: ${result.error.message}`) + throw new Error( + `Generated OpenCode config is invalid: ${result.issues.map((issue) => `${issue.path}: ${issue.message}`).join(', ')}`, + ) } return config @@ -1186,44 +1199,32 @@ export async function ensureAssistantMode( await writeFileContent(agentsMdPath, agentsMdContent) } - const hasLegacyOpenCodeConfig = opencodeJsonExists && await isLegacyAssistantOpenCodeConfig(opencodeJsonPath) + const existingOpenCodeJsonContent = opencodeJsonExists ? await readFileContent(opencodeJsonPath) : undefined + const parsedOpenCodeConfig = existingOpenCodeJsonContent === undefined + ? undefined + : parseAssistantOpenCodeConfig(existingOpenCodeJsonContent) + const hasV1OpenCodeConfig = parsedOpenCodeConfig !== undefined && hasAssistantV1OpenCodeConfig(parsedOpenCodeConfig) + const existingOpenCodeConfig = parsedOpenCodeConfig as OpenCodeConfigInfo | undefined let opencodeJsonUpdated = false - if (!opencodeJsonExists || overwriteOpenCodeConfig || hasLegacyOpenCodeConfig) { - const config = hasLegacyOpenCodeConfig && opencodeJsonExists - ? await (async () => { - try { - const existingContent = await readFileContent(opencodeJsonPath) - const existingConfig = JSON.parse(existingContent) as OpenCodeConfigInput - const mergedConfig = mergeAssistantOpenCodeConfig(existingConfig) - return assistantOpenCodeConfigHasGeneratedAgentPersona(mergedConfig) - ? stripGeneratedAssistantAgentPersona(mergedConfig) - : mergedConfig - } catch { - return buildAssistantOpenCodeConfig() - } - })() - : buildAssistantOpenCodeConfig() - await writeFileContent(opencodeJsonPath, JSON.stringify(config, null, 2)) + if (!opencodeJsonExists || overwriteOpenCodeConfig) { + await writeFileContent(opencodeJsonPath, JSON.stringify(buildAssistantOpenCodeConfig(), null, 2)) opencodeJsonUpdated = true - } else if (opencodeJsonExists) { - try { - const existingContent = await readFileContent(opencodeJsonPath) - const existingConfig = JSON.parse(existingContent) as OpenCodeConfigInput - const repairedConfig = assistantOpenCodeConfigNeedsRepair(existingConfig) - ? mergeAssistantOpenCodeConfig(existingConfig) - : existingConfig + } else if (!hasV1OpenCodeConfig) { + if (existingOpenCodeConfig !== undefined) { + const repairedConfig = assistantOpenCodeConfigNeedsRepair(existingOpenCodeConfig) + ? mergeAssistantOpenCodeConfig(existingOpenCodeConfig) + : existingOpenCodeConfig const updatedConfig = assistantOpenCodeConfigHasGeneratedAgentPersona(repairedConfig) ? stripGeneratedAssistantAgentPersona(repairedConfig) : repairedConfig - if (updatedConfig !== existingConfig) { + if (updatedConfig !== existingOpenCodeConfig) { await writeFileContent(opencodeJsonPath, JSON.stringify(updatedConfig, null, 2)) opencodeJsonUpdated = true } - } catch { - const config = buildAssistantOpenCodeConfig() - await writeFileContent(opencodeJsonPath, JSON.stringify(config, null, 2)) + } else { + await writeFileContent(opencodeJsonPath, JSON.stringify(buildAssistantOpenCodeConfig(), null, 2)) opencodeJsonUpdated = true } } @@ -1350,23 +1351,51 @@ export async function ensureAssistantMode( } } -function assistantOpenCodeConfigNeedsRepair(config: OpenCodeConfigInput): boolean { +function parseAssistantOpenCodeConfig(rawContent: string): Record | undefined { + try { + const parsed = parseJsonc(rawContent) + return isRecord(parsed) ? parsed : undefined + } catch { + return undefined + } +} + +/** + * Resolves the v1 container that OpenCode converts into the given v2 container. + */ +function resolveV1EntityPath(v2Path: readonly string[]): string[] { + const mapping = V1_ENTITY_PATHS.find( + (entry) => entry.v2Path.length === v2Path.length && entry.v2Path.every((segment, index) => segment === v2Path[index]), + ) + if (mapping === undefined) { + throw new Error(`No v1 config path is defined for ${v2Path.join('.')}`) + } + return [...mapping.v1Path] +} + +const ASSISTANT_V1_AGENT_PATH = resolveV1EntityPath(['agents']) +const ASSISTANT_V1_PERMISSION_PATH = resolveV1EntityPath(['permissions']) + +function hasAssistantV1OpenCodeConfig(config: Record): boolean { + if (readConfigValueAtPath(config, ASSISTANT_V1_PERMISSION_PATH) !== undefined) return true + const v1Agents = readConfigValueAtPath(config, ASSISTANT_V1_AGENT_PATH) + return isRecord(v1Agents) && isRecord(v1Agents[ASSISTANT_DEFAULT_AGENT_NAME]) +} + +function assistantOpenCodeConfigNeedsRepair(config: OpenCodeConfigInfo): boolean { if (config.default_agent !== ASSISTANT_DEFAULT_AGENT_NAME) return true - if (!config.agent || typeof config.agent !== 'object') return true - const assistantAgent = config.agent[ASSISTANT_DEFAULT_AGENT_NAME] + const agents = config.agents + if (!agents || typeof agents !== 'object') return true + const assistantAgent = agents[ASSISTANT_DEFAULT_AGENT_NAME] if (!assistantAgent || typeof assistantAgent !== 'object') return true - const mode = (assistantAgent as { mode?: unknown }).mode - if (mode !== 'primary' && mode !== 'all') return true - if ((assistantAgent as { disable?: unknown }).disable === true) return true + if (assistantAgent.mode !== 'primary' && assistantAgent.mode !== 'all') return true + if (assistantAgent.disabled === true) return true if (assistantOpenCodeConfigHasGeneratedAgentPersona(config)) return true return false } -function assistantOpenCodeConfigHasGeneratedAgentPersona(config: OpenCodeConfigInput): boolean { - const agent = config.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] - if (typeof agent !== 'object' || agent === null) return false - const prompt = (agent as { prompt?: unknown }).prompt - return matchesGeneratedAssistantAgentPrompt(prompt) +function assistantOpenCodeConfigHasGeneratedAgentPersona(config: OpenCodeConfigInfo): boolean { + return normalizeAssistantAgent(config.agents?.[ASSISTANT_DEFAULT_AGENT_NAME]) !== undefined } function resolveValidAssistantMode(agent: unknown): 'primary' | 'all' { @@ -1374,63 +1403,137 @@ function resolveValidAssistantMode(agent: unknown): 'primary' | 'all' { return mode === 'primary' || mode === 'all' ? mode : 'primary' } -function stripGeneratedAssistantAgentPersona(config: OpenCodeConfigInput): OpenCodeConfigInput { - const existingAssistantAgent = config.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] - const validMode = resolveValidAssistantMode(existingAssistantAgent) +/** + * Reduces a generated assistant persona to a mode-only agent, leaving a customized persona to the caller. + */ +function normalizeAssistantAgent(agent: unknown): OpenCodeAgentConfig | undefined { + if (!isRecord(agent) || !matchesGeneratedAssistantAgentPrompt(agent.system)) return undefined + return { mode: resolveValidAssistantMode(agent) } +} + +function stripGeneratedAssistantAgentPersona(config: OpenCodeConfigInfo): OpenCodeConfigInfo { + const existingAssistantAgent = config.agents?.[ASSISTANT_DEFAULT_AGENT_NAME] return { ...config, - agent: { - ...(config.agent ?? {}), - [ASSISTANT_DEFAULT_AGENT_NAME]: { mode: validMode }, + agents: { + ...(config.agents ?? {}), + [ASSISTANT_DEFAULT_AGENT_NAME]: normalizeAssistantAgent(existingAssistantAgent) ?? { + mode: resolveValidAssistantMode(existingAssistantAgent), + }, }, } } -function mergeAssistantOpenCodeConfig(existing?: OpenCodeConfigInput): OpenCodeConfigInput { +function mergeAssistantOpenCodeConfig(existing?: OpenCodeConfigInfo): OpenCodeConfigInfo { const generated = buildAssistantOpenCodeConfig() - const existingAssistantAgent = existing?.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] + const existingAssistantAgent = existing?.agents?.[ASSISTANT_DEFAULT_AGENT_NAME] const validMode = resolveValidAssistantMode(existingAssistantAgent) - - const existingIsGenerated = existingAssistantAgent != null && - typeof existingAssistantAgent === 'object' && - matchesGeneratedAssistantAgentPrompt( - (existingAssistantAgent as { prompt?: unknown }).prompt, - ) - - let mergedAssistantAgent: Record - if (existingIsGenerated) { - mergedAssistantAgent = { mode: validMode } - } else { - mergedAssistantAgent = { - ...(typeof existingAssistantAgent === 'object' && existingAssistantAgent !== null ? existingAssistantAgent : {}), - mode: validMode, - disable: false, - } + const mergedAssistantAgent: OpenCodeAgentConfig = normalizeAssistantAgent(existingAssistantAgent) ?? { + ...(existingAssistantAgent ?? {}), + mode: validMode, + disabled: false, } return { ...generated, ...existing, default_agent: ASSISTANT_DEFAULT_AGENT_NAME, - instructions: existing?.instructions ?? generated.instructions, - permission: existing?.permission ?? generated.permission, - agent: { - ...(existing?.agent ?? {}), + permissions: existing?.permissions ?? generated.permissions, + agents: { + ...(existing?.agents ?? {}), [ASSISTANT_DEFAULT_AGENT_NAME]: mergedAssistantAgent, }, } } -async function isLegacyAssistantOpenCodeConfig(opencodeJsonPath: string): Promise { +function buildAssistantV1ConfigRemovalPaths( + config: Record, + convertedPermissions: unknown, +): string[][] { + const v1Agent = readConfigValueAtPath(config, ASSISTANT_V1_AGENT_PATH) + const removeWholeAgent = isRecord(v1Agent) && + Object.keys(v1Agent).length === 1 && + isRecord(v1Agent[ASSISTANT_DEFAULT_AGENT_NAME]) + + const paths: string[][] = [ + removeWholeAgent ? ASSISTANT_V1_AGENT_PATH : [...ASSISTANT_V1_AGENT_PATH, ASSISTANT_DEFAULT_AGENT_NAME], + ] + if (convertedPermissions !== undefined && readConfigValueAtPath(config, ASSISTANT_V1_PERMISSION_PATH) !== undefined) { + paths.push(ASSISTANT_V1_PERMISSION_PATH) + } + return paths +} + +function resolveConvertedAssistantAgent( + agent: OpenCodeAgentConfig | undefined, + v1Agent: unknown, +): OpenCodeAgentConfig { + return normalizeAssistantAgent(agent) ?? agent ?? { mode: resolveValidAssistantMode(v1Agent) } +} + +function restoreAssistantAgentReferences( + agent: OpenCodeAgentConfig, + resolved: OpenCodeConfigInfo, + raw: Record, +): OpenCodeAgentConfig { + const restored = restoreOpenCodeConfigReferences( + { agents: { [ASSISTANT_DEFAULT_AGENT_NAME]: agent } }, + resolved as Record, + raw, + ) + const restoredAgent = readConfigValueAtPath(restored, ['agents', ASSISTANT_DEFAULT_AGENT_NAME]) + if (typeof restoredAgent !== 'object' || restoredAgent === null) return agent + return restoredAgent as OpenCodeAgentConfig +} + +/** + * Rewrites a v1 Assistant `opencode.json` to the v2 shape using OpenCode's own conversion, + * keeping comments and formatting. The file is left untouched when OpenCode is unreachable. + */ +export async function convertAssistantOpenCodeConfigToV2(api: OpenCodeApi): Promise { try { - const content = await readFileContent(opencodeJsonPath) - const config = JSON.parse(content) as { - permission?: { allow?: unknown; ask?: unknown } + const assistantDir = getAssistantModeDirectory() + const opencodeJsonPath = path.join(assistantDir, ASSISTANT_OPENCODE_CONFIG_FILENAME) + + if (!await fileExists(opencodeJsonPath)) return false + + const rawContent = await readFileContent(opencodeJsonPath) + const rawConfig = parseAssistantOpenCodeConfig(rawContent) + if (rawConfig === undefined || !hasAssistantV1OpenCodeConfig(rawConfig)) return false + + const documents = await readOpenCodeConfigDocuments(api, assistantDir) + const documentInfo = documents.get(path.resolve(opencodeJsonPath)) + if (documentInfo === undefined) return false + + const v1Assistants = readConfigValueAtPath(rawConfig, ASSISTANT_V1_AGENT_PATH) + const v1AssistantAgent = isRecord(v1Assistants) ? v1Assistants[ASSISTANT_DEFAULT_AGENT_NAME] : undefined + const setOperations: OpenCodeConfigPathOperation[] = [ + { + path: ['agents', ASSISTANT_DEFAULT_AGENT_NAME], + value: restoreAssistantAgentReferences( + resolveConvertedAssistantAgent( + documentInfo.agents?.[ASSISTANT_DEFAULT_AGENT_NAME], + v1AssistantAgent, + ), + documentInfo, + rawConfig, + ), + }, + ] + if (documentInfo.permissions !== undefined) { + setOperations.push({ path: ['permissions'], value: documentInfo.permissions }) } - if (Array.isArray(config.permission?.allow) || Array.isArray(config.permission?.ask)) return true - return false - } catch { + + let updatedText = applyOpenCodeConfigEdits(rawContent, setOperations) + for (const removalPath of buildAssistantV1ConfigRemovalPaths(rawConfig, documentInfo.permissions)) { + updatedText = removeOpenCodeConfigProperty(updatedText, removalPath) + } + + await writeFileContent(opencodeJsonPath, updatedText) + return true + } catch (error) { + logger.warn('Skipped the Assistant OpenCode config conversion:', error) return false } } diff --git a/backend/src/services/opencode-config-apply.ts b/backend/src/services/opencode-config-apply.ts index 0cc76be2f..c2f479f68 100644 --- a/backend/src/services/opencode-config-apply.ts +++ b/backend/src/services/opencode-config-apply.ts @@ -10,9 +10,12 @@ import { restoreOpenCodeConfigSnapshot, serializeOpenCodeConfigSnapshot, updateOpenCodeConfigFile, + type OpenCodeConfigSnapshot, type OpenCodeConfigUpdateMode, withOpenCodeConfigLock, } from './opencode-config-file' +import { readManagedOpenCodeConfig } from './opencode-config-effective' +import { patchNamesOpenCodeConfigEntities, restoreOpenCodeConfigReferences } from './opencode-config-references' import { opencodeServerManager } from './opencode-single-server' import { reloadOpenCodeConfig } from './opencode-restart' import type { OpenCodeClient } from './opencode/client' @@ -26,7 +29,7 @@ export type ApplyOpenCodeConfigResult = | { status: 'reloaded'; config: OpenCodeConfigFile } | { status: 'restart_pending'; config: OpenCodeConfigFile } -export interface ApplyOpenCodeConfigInput { +export interface ApplyOpenCodeConfigOptions { content: Record | string source?: OpenCodeConfigSourceName expectedRevision?: string @@ -98,16 +101,29 @@ function requiresOpenCodeReload(previous: OpenCodeConfigFile | null, next: OpenC return listChangedTopLevelKeys(previous?.content ?? {}, next.content).length > 0 } +async function keepOpenCodeConfigReferences( + patch: Record, + snapshot: OpenCodeConfigSnapshot, + openCodeClient: OpenCodeClient, +): Promise> { + if (!patchNamesOpenCodeConfigEntities(patch)) return patch + const resolved = await readManagedOpenCodeConfig(openCodeClient.api, snapshot.sources) + return restoreOpenCodeConfigReferences(patch, resolved as Record, snapshot.content) +} + export async function applyOpenCodeConfigUpdate( - input: ApplyOpenCodeConfigInput, + input: ApplyOpenCodeConfigOptions, ): Promise { const { reloadRequired, config } = await withOpenCodeConfigLock(async () => { const { content, source, expectedRevision, mode, settingsService } = input const snapshot = await readOpenCodeConfigSnapshot() const previous = await readOpenCodeConfigFile(snapshot) + const savedContent = mode === 'merge' && typeof content !== 'string' + ? await keepOpenCodeConfigReferences(content, snapshot, input.openCodeClient) + : content - const next = await updateOpenCodeConfigFile(content, { source, expectedRevision, snapshot, mode }) + const next = await updateOpenCodeConfigFile(savedContent, { source, expectedRevision, snapshot, mode }) if (previous?.isValid) { const snapshot = serializeOpenCodeConfigSnapshot(previous) diff --git a/backend/src/services/opencode-config-effective.ts b/backend/src/services/opencode-config-effective.ts new file mode 100644 index 000000000..5b9427cd1 --- /dev/null +++ b/backend/src/services/opencode-config-effective.ts @@ -0,0 +1,45 @@ +import path from 'path' +import { getWorkspacePath } from '@opencode-manager/shared/config/env' +import { + openCodeLocation, + type OpenCodeApi, + type OpenCodeConfigInfo, +} from '@opencode-manager/shared/opencode' +import { mergeOpenCodeConfigValues } from './opencode-config-file' + +interface ManagedConfigSource { + path: string +} + +/** + * Reads the configuration documents OpenCode has loaded for a directory, + * keyed by each document's resolved file path. + */ +export async function readOpenCodeConfigDocuments( + api: OpenCodeApi, + directory: string, +): Promise> { + const entries = await api.config.get(openCodeLocation(directory)) + const infoByPath = new Map() + + for (const entry of entries) { + if (entry.type !== 'document' || !entry.path) continue + infoByPath.set(path.resolve(entry.path), entry.info) + } + + return infoByPath +} + +export async function readManagedOpenCodeConfig( + api: OpenCodeApi, + sources: ReadonlyArray, +): Promise { + const infoByPath = await readOpenCodeConfigDocuments(api, getWorkspacePath()) + + const merged = sources.reduce>((current, source) => { + const info = infoByPath.get(path.resolve(source.path)) + return info ? mergeOpenCodeConfigValues(current, info as Record) : current + }, {}) + + return merged as OpenCodeConfigInfo +} diff --git a/backend/src/services/opencode-config-file.ts b/backend/src/services/opencode-config-file.ts index e0044b9d5..ec3b3a9a6 100644 --- a/backend/src/services/opencode-config-file.ts +++ b/backend/src/services/opencode-config-file.ts @@ -3,8 +3,7 @@ import { promises as fs } from 'fs' import { readFile, readdir, rename, rm, stat } from 'fs/promises' import path from 'path' import { isDeepStrictEqual } from 'node:util' -import { applyEdits, modify, type JSONPath } from 'jsonc-parser' -import type { ZodIssue } from 'zod' +import { applyEdits, findNodeAtLocation, modify, parseTree, type Edit, type JSONPath } from 'jsonc-parser' import { getConfigPath, getOpenCodeConfigHome, getOpenCodeHealthWatchPath } from '@opencode-manager/shared/config/env' import { DEFAULT_OPENCODE_CONFIG_SOURCE_NAME, @@ -13,7 +12,7 @@ import { isOpenCodeConfigSourceName, selectPreferredOpenCodeConfigSourceName, } from '@opencode-manager/shared' -import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' +import { validateOpenCodeConfig } from '@opencode-manager/shared/opencode/config-validation' import { parseJsonc } from '@opencode-manager/shared/utils' import type { OpenCodeConfigFile, @@ -107,6 +106,16 @@ export class OpenCodeConfigRedactedValueError extends Error { } } +export class OpenCodeConfigValidationError extends Error { + readonly issues: OpenCodeConfigValidationIssue[] + + constructor(issues: OpenCodeConfigValidationIssue[]) { + super('OpenCode config is invalid') + this.name = 'OpenCodeConfigValidationError' + this.issues = issues + } +} + interface OpenCodeConfigParseResult { content: Record isValid: boolean @@ -139,11 +148,32 @@ interface AppliedOpenCodeConfigSourceFileState { previousMode: number | undefined } -interface OpenCodeConfigPathOperation { +export interface OpenCodeConfigPathOperation { path: JSONPath value: unknown } +/** Maps a v2 config entity container to the v1 container OpenCode converts it from. */ +export interface OpenCodeConfigV1EntityPath { + v2Path: string[] + v1Path: string[] + named: boolean + reservedNames?: readonly string[] +} + +interface OpenCodeConfigV1EntityRemoval { + path: JSONPath + container?: JSONPath +} + +export const V1_ENTITY_PATHS: readonly OpenCodeConfigV1EntityPath[] = [ + { v2Path: ['agents'], v1Path: ['agent'], named: true }, + { v2Path: ['commands'], v1Path: ['command'], named: true }, + { v2Path: ['providers'], v1Path: ['provider'], named: true }, + { v2Path: ['mcp', 'servers'], v1Path: ['mcp'], named: true, reservedNames: ['servers', 'timeout'] }, + { v2Path: ['permissions'], v1Path: ['permission'], named: false }, +] + interface OpenCodeConfigSnapshotEnvelopeSource { name: OpenCodeConfigRestorableSourceName rawContent: string @@ -178,18 +208,33 @@ function hasOwn(object: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(object, key) } -function hasOpenCodeConfigPath(content: Record, jsonPath: JSONPath): boolean { +interface ReadOpenCodeConfigPathOptions { + nullAncestorCountsAsFound?: boolean +} + +function readOpenCodeConfigPath( + content: Record, + jsonPath: JSONPath, + options: ReadOpenCodeConfigPathOptions = {}, +): { found: boolean; value: unknown } { let current: unknown = content for (const segment of jsonPath) { + if (options.nullAncestorCountsAsFound && current === null) return { found: true, value: null } if (typeof segment === 'number') { - if (!Array.isArray(current) || segment < 0 || segment >= current.length) return false + if (!Array.isArray(current) || segment < 0 || segment >= current.length) { + return { found: false, value: undefined } + } current = current[segment] continue } - if (!isPlainObject(current) || !hasOwn(current, segment)) return false + if (!isPlainObject(current) || !hasOwn(current, segment)) return { found: false, value: undefined } current = current[segment] } - return true + return { found: true, value: current } +} + +function hasOpenCodeConfigPath(content: Record, jsonPath: JSONPath): boolean { + return readOpenCodeConfigPath(content, jsonPath).found } function defineOwnConfigValue(target: Record, key: string, value: unknown): void { @@ -238,7 +283,7 @@ interface MergeOpenCodeConfigValuesOptions { deleteNullValues?: boolean } -function mergeOpenCodeConfigValues( +export function mergeOpenCodeConfigValues( target: Record, source: Record, options: MergeOpenCodeConfigValuesOptions = {}, @@ -271,11 +316,9 @@ export function withOpenCodeConfigLock(fn: () => Promise): Promise { return withFileLock(getOpenCodeConfigDirectory(), fn) } -export function toOpenCodeConfigValidationIssues(issues: ZodIssue[]): OpenCodeConfigValidationIssue[] { - return issues.map((issue) => ({ - path: issue.path.length > 0 ? issue.path.join('.') : 'root', - message: issue.message, - })) +function assertValidOpenCodeConfig(value: unknown): void { + const result = validateOpenCodeConfig(value) + if (!result.success) throw new OpenCodeConfigValidationError(result.issues) } export function parseOpenCodeConfigContent(rawContent: string): OpenCodeConfigParseResult { @@ -294,7 +337,7 @@ export function parseOpenCodeConfigContent(rawContent: string): OpenCodeConfigPa const content = isPlainObject(parsed) ? parsed : {} - const validated = OpenCodeConfigSchema.safeParse(parsed) + const validated = validateOpenCodeConfig(parsed) if (validated.success) { return { content, @@ -302,7 +345,7 @@ export function parseOpenCodeConfigContent(rawContent: string): OpenCodeConfigPa } } - const validationIssues = toOpenCodeConfigValidationIssues(validated.error.issues) + const validationIssues = validated.issues logger.error(`Failed to validate OpenCode config: ${validationIssues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`) return { @@ -402,7 +445,7 @@ export async function writeOpenCodeConfigFile( source?: OpenCodeConfigSourceName, snapshot?: OpenCodeConfigSnapshot, ): Promise { - OpenCodeConfigSchema.parse(parseJsonc(rawContent)) + assertValidOpenCodeConfig(parseJsonc(rawContent)) const resolved = snapshot ?? await readOpenCodeConfigSnapshot() const targetName = source !== undefined @@ -452,7 +495,140 @@ function collectOpenCodeConfigPathOperations( return operations } -function applyOpenCodeConfigPathOperations( +function collectOpenCodeConfigV1EntityRemovals( + patch: Record, +): OpenCodeConfigV1EntityRemoval[] { + const removals: OpenCodeConfigV1EntityRemoval[] = [] + for (const mapping of V1_ENTITY_PATHS) { + const patched = readOpenCodeConfigPath(patch, mapping.v2Path, { nullAncestorCountsAsFound: true }) + if (!patched.found) continue + if (!mapping.named || patched.value === null) { + if (!mapping.reservedNames) removals.push({ path: mapping.v1Path }) + continue + } + if (!isPlainObject(patched.value)) continue + for (const name of Object.keys(patched.value)) { + if (mapping.reservedNames?.includes(name)) continue + removals.push({ path: [...mapping.v1Path, name], container: mapping.v1Path }) + } + } + return removals +} + +function findOpenCodeConfigSeparatorComma(rawContent: string, start: number, end: number): number { + let index = start + while (index < end) { + if (rawContent.startsWith('//', index)) { + const newline = rawContent.indexOf('\n', index) + if (newline < 0 || newline >= end) return -1 + index = newline + 1 + continue + } + if (rawContent.startsWith('/*', index)) { + const close = rawContent.indexOf('*/', index + 2) + if (close < 0 || close >= end) return -1 + index = close + 2 + continue + } + if (rawContent[index] === ',') return index + index += 1 + } + return -1 +} + +function findOpenCodeConfigLineEnd(rawContent: string, offset: number): number | null { + let index = offset + while (index < rawContent.length && (rawContent[index] === ' ' || rawContent[index] === '\t')) { + index += 1 + } + if (rawContent.startsWith('\r\n', index)) return index + 2 + if (rawContent[index] === '\n') return index + 1 + return null +} + +function buildOpenCodeConfigCommentPreservingRemovalEdits( + rawContent: string, + jsonPath: JSONPath, +): Edit[] | null { + const root = parseTree(rawContent) + const node = root ? findNodeAtLocation(root, jsonPath) : undefined + const property = node?.parent + const container = property?.parent + if (!property || !container?.children) return null + + const index = container.children.indexOf(property) + const previous = index > 0 ? container.children[index - 1] : undefined + const next = container.children[index + 1] + const propertyEnd = property.offset + property.length + const lineStart = rawContent.lastIndexOf('\n', property.offset - 1) + 1 + const aloneOnLine = rawContent.slice(lineStart, property.offset).trim() === '' + const removeStart = aloneOnLine ? lineStart : property.offset + + if (next) { + const separatorOffset = findOpenCodeConfigSeparatorComma(rawContent, propertyEnd, next.offset) + if (separatorOffset < 0) return null + if (rawContent.slice(propertyEnd, separatorOffset).includes('/')) { + return [ + { offset: removeStart, length: propertyEnd - removeStart, content: '' }, + { offset: separatorOffset, length: 1, content: '' }, + ] + } + const lineEnd = aloneOnLine ? findOpenCodeConfigLineEnd(rawContent, separatorOffset + 1) : null + const removeEnd = lineEnd ?? separatorOffset + 1 + return [{ offset: removeStart, length: removeEnd - removeStart, content: '' }] + } + + const edits: Edit[] = [] + if (previous) { + const separatorOffset = findOpenCodeConfigSeparatorComma( + rawContent, + previous.offset + previous.length, + property.offset, + ) + if (separatorOffset < 0) return null + edits.push({ offset: separatorOffset, length: 1, content: '' }) + } + const lineEnd = aloneOnLine ? findOpenCodeConfigLineEnd(rawContent, propertyEnd) : null + const removeEnd = lineEnd ?? propertyEnd + edits.push({ offset: removeStart, length: removeEnd - removeStart, content: '' }) + return edits +} + +/** + * Removes one property from raw JSONC config text while keeping neighbouring comments and formatting. + */ +export function removeOpenCodeConfigProperty(rawContent: string, jsonPath: JSONPath): string { + const edits = buildOpenCodeConfigCommentPreservingRemovalEdits(rawContent, jsonPath) + if (!edits) return rawContent + return applyEdits(rawContent, edits) +} + +function collectEmptyOpenCodeConfigContainers( + rawContent: string, + removals: OpenCodeConfigV1EntityRemoval[], +): JSONPath[] { + const parsed = parseJsonc(rawContent) + if (!isPlainObject(parsed)) return [] + + const containers: JSONPath[] = [] + const visited = new Set() + for (const removal of removals) { + if (!removal.container) continue + const key = removal.container.join('\0') + if (visited.has(key)) continue + visited.add(key) + const container = readOpenCodeConfigPath(parsed, removal.container) + if (isPlainObject(container.value) && Object.keys(container.value).length === 0) { + containers.push(removal.container) + } + } + return containers +} + +/** + * Applies path edits to raw JSONC config text, preserving comments and formatting. + */ +export function applyOpenCodeConfigEdits( rawContent: string, operations: OpenCodeConfigPathOperation[], ): string { @@ -525,17 +701,30 @@ export async function updateOpenCodeConfigFile( const mode = options.mode ?? 'replace' const operations = collectOpenCodeConfigPathOperations(content, snapshot.content, mode) + const v1EntityRemovals = mode === 'merge' ? collectOpenCodeConfigV1EntityRemovals(content) : [] - OpenCodeConfigSchema.parse( + assertValidOpenCodeConfig( mode === 'merge' ? mergeOpenCodeConfigValues(snapshot.content, content, { deleteNullValues: true }) : content, ) const originalText = targetSource?.rawContent ?? '{}\n' - assertNoShadowedOpenCodeConfigRemovals(operations, targetSource, snapshot.sources, targetName) - const updatedText = applyOpenCodeConfigPathOperations(originalText, operations) + assertNoShadowedOpenCodeConfigRemovals( + [...operations, ...v1EntityRemovals.map((removal) => ({ path: removal.path, value: undefined }))], + targetSource, + snapshot.sources, + targetName, + ) + + let updatedText = applyOpenCodeConfigEdits(originalText, operations) + for (const removal of v1EntityRemovals) { + updatedText = removeOpenCodeConfigProperty(updatedText, removal.path) + } + for (const container of collectEmptyOpenCodeConfigContainers(updatedText, v1EntityRemovals)) { + updatedText = removeOpenCodeConfigProperty(updatedText, container) + } if (updatedText !== originalText) { - OpenCodeConfigSchema.parse(parseJsonc(updatedText)) + assertValidOpenCodeConfig(parseJsonc(updatedText)) await writeFileAtomic(targetPath, updatedText, { mode: await existingFileMode(targetPath) }) } @@ -754,8 +943,8 @@ export async function foldLegacyConfigJsonSource(): Promise { const operations = collectOpenCodeConfigPathOperations(legacyContent, lowestPrecedenceSource.content, 'replace') .filter((operation) => operation.value !== undefined && !hasOpenCodeConfigPath(lowestPrecedenceSource.content, operation.path)) if (operations.length > 0) { - const updatedText = applyOpenCodeConfigPathOperations(lowestPrecedenceSource.rawContent, operations) - OpenCodeConfigSchema.parse(parseJsonc(updatedText)) + const updatedText = applyOpenCodeConfigEdits(lowestPrecedenceSource.rawContent, operations) + assertValidOpenCodeConfig(parseJsonc(updatedText)) await writeFileAtomic(lowestPrecedenceSource.path, updatedText, { mode: await existingFileMode(lowestPrecedenceSource.path) }) } await rm(legacyPath, { force: true }) diff --git a/backend/src/services/opencode-config-redact.ts b/backend/src/services/opencode-config-redact.ts index 2b91bbdcc..bc52f5693 100644 --- a/backend/src/services/opencode-config-redact.ts +++ b/backend/src/services/opencode-config-redact.ts @@ -1,5 +1,5 @@ +import { isRecord } from '@opencode-manager/shared/utils' import type { OpenCodeConfigFile } from '../types/settings' -import { isRecord } from './opencode/enforcement-config' import { OPENCODE_CONFIG_REDACTED_VALUE } from './opencode-config-file' const SECRET_KEYS = new Set([ @@ -55,22 +55,18 @@ function isSecretKey(key: string): boolean { function keysAreNames(path: string[]): boolean { const [head] = path - if (head === 'provider') { + if (head === 'providers') { return path.length === 1 || (path.length === 3 && path[2] === 'models') } - if (head === 'agent') { + if (head === 'agents' || head === 'commands') { return path.length === 1 } if (head === 'mcp') { - return path.length === 1 || (path.length === 2 && path[1] === 'servers') + return path.length === 2 && path[1] === 'servers' } return false } -function isNameMapException(key: string): boolean { - return key === 'servers' || key === 'timeout' -} - function redactOpaqueValue(value: unknown, path: string[], redactedPaths: string[]): unknown { if (Array.isArray(value)) { return value.map((item, index) => redactOpaqueValue(item, [...path, String(index)], redactedPaths)) @@ -95,7 +91,7 @@ function redactValue(value: unknown, path: string[], redactedPaths: string[]): u return Object.fromEntries( Object.entries(value).map(([key, child]) => { const childPath = [...path, key] - if (names && !isNameMapException(key)) { + if (names) { return [key, redactValue(child, childPath, redactedPaths)] } if (OPAQUE_VALUE_KEYS.has(key)) { diff --git a/backend/src/services/opencode-config-references.ts b/backend/src/services/opencode-config-references.ts new file mode 100644 index 000000000..4efe006f1 --- /dev/null +++ b/backend/src/services/opencode-config-references.ts @@ -0,0 +1,161 @@ +import { isRecord } from '@opencode-manager/shared/utils' +import { V1_ENTITY_PATHS, type OpenCodeConfigV1EntityPath } from './opencode-config-file' + +const CONFIG_REFERENCE_PATTERN = /\{(?:env|file):[^}]+\}/ + +type FieldAlias = readonly [v2Prefix: readonly string[], v1Prefix: readonly string[]] + +const V1_FIELD_ALIASES: Readonly> = { + agents: [ + [['system'], ['prompt']], + [['request', 'body', 'temperature'], ['temperature']], + [['request', 'body', 'top_p'], ['top_p']], + [['request', 'body'], ['options']], + ], + providers: [ + [['settings', 'baseURL'], ['api']], + [['headers'], ['options', 'headers']], + [['body'], ['options', 'body']], + [['settings'], ['options']], + ], + 'mcp.servers': [ + [['oauth', 'client_id'], ['oauth', 'clientId']], + [['oauth', 'client_secret'], ['oauth', 'clientSecret']], + [['oauth', 'redirect_uri'], ['oauth', 'redirectUri']], + ], +} + +interface RawEntityCandidate { + entity: unknown + aliases: readonly FieldAlias[] +} + +/** Reads a nested config value by object keys and array indices, or `undefined` when absent. */ +export function readConfigValueAtPath(value: unknown, path: readonly string[]): unknown { + let current = value + for (const segment of path) { + if (Array.isArray(current)) { + current = current[Number(segment)] + continue + } + if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) return undefined + current = current[segment] + } + return current +} + +function startsWithPath(path: readonly string[], prefix: readonly string[]): boolean { + return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment) +} + +function readRawReference(candidates: readonly RawEntityCandidate[], path: readonly string[]): string | undefined { + for (const candidate of candidates) { + const paths = [ + path, + ...candidate.aliases + .filter(([v2Prefix]) => startsWithPath(path, v2Prefix)) + .map(([v2Prefix, v1Prefix]) => [...v1Prefix, ...path.slice(v2Prefix.length)]), + ] + for (const rawPath of paths) { + const raw = readConfigValueAtPath(candidate.entity, rawPath) + if (typeof raw === 'string' && CONFIG_REFERENCE_PATTERN.test(raw)) return raw + } + } + return undefined +} + +function restoreEntityReferences( + next: unknown, + path: readonly string[], + resolvedEntity: unknown, + candidates: readonly RawEntityCandidate[], +): unknown { + if (typeof next === 'string') { + if (readConfigValueAtPath(resolvedEntity, path) !== next) return next + return readRawReference(candidates, path) ?? next + } + if (Array.isArray(next)) { + return next.map((item, index) => restoreEntityReferences(item, [...path, String(index)], resolvedEntity, candidates)) + } + if (isRecord(next)) { + return Object.fromEntries( + Object.entries(next).map(([key, value]) => [key, restoreEntityReferences(value, [...path, key], resolvedEntity, candidates)]), + ) + } + return next +} + +function findRenameSource( + patchContainer: Record, + resolvedContainer: Record, +): string | undefined { + const removed = Object.keys(patchContainer).filter( + (name) => patchContainer[name] === null && Object.prototype.hasOwnProperty.call(resolvedContainer, name), + ) + return removed.length === 1 ? removed[0] : undefined +} + +function restoreContainerReferences( + mapping: OpenCodeConfigV1EntityPath, + patchContainer: Record, + resolved: Record, + raw: Record, +): Record { + const resolvedContainer = readConfigValueAtPath(resolved, mapping.v2Path) + if (!isRecord(resolvedContainer)) return patchContainer + const rawV2Container = readConfigValueAtPath(raw, mapping.v2Path) + const rawV1Container = readConfigValueAtPath(raw, mapping.v1Path) + const aliases = V1_FIELD_ALIASES[mapping.v2Path.join('.')] ?? [] + + return Object.fromEntries( + Object.entries(patchContainer).map(([name, value]) => { + if (!isRecord(value)) return [name, value] + const sourceName = Object.prototype.hasOwnProperty.call(resolvedContainer, name) + ? name + : findRenameSource(patchContainer, resolvedContainer) + if (sourceName === undefined) return [name, value] + + const candidates: RawEntityCandidate[] = [ + { entity: isRecord(rawV2Container) ? rawV2Container[sourceName] : undefined, aliases: [] }, + ] + if (!mapping.reservedNames?.includes(sourceName)) { + candidates.push({ entity: isRecord(rawV1Container) ? rawV1Container[sourceName] : undefined, aliases }) + } + return [name, restoreEntityReferences(value, [], resolvedContainer[sourceName], candidates)] + }), + ) +} + +function setValueAtPath(target: Record, path: readonly string[], value: unknown): Record { + const [head, ...rest] = path + if (head === undefined) return target + const child = target[head] + return { + ...target, + [head]: rest.length === 0 ? value : setValueAtPath(isRecord(child) ? child : {}, rest, value), + } +} + +/** Whether a merge patch names an agent, command, provider or MCP server, the only places references are restored. */ +export function patchNamesOpenCodeConfigEntities(patch: Record): boolean { + return V1_ENTITY_PATHS.some((mapping) => mapping.named && isRecord(readConfigValueAtPath(patch, mapping.v2Path))) +} + +/** + * Keeps `{env:…}` and `{file:…}` references in a merge patch of named config entities. + * + * Settings edits entities from OpenCode's resolved config, so an untouched field arrives with its + * substituted value. A string that still equals the resolved value is replaced by the reference + * stored in the raw config: the same entity, the entity being renamed, or its v1 definition. + */ +export function restoreOpenCodeConfigReferences( + patch: Record, + resolved: Record, + raw: Record, +): Record { + return V1_ENTITY_PATHS.filter((mapping) => mapping.named).reduce((current, mapping) => { + const patchContainer = readConfigValueAtPath(current, mapping.v2Path) + if (!isRecord(patchContainer)) return current + return setValueAtPath(current, mapping.v2Path, restoreContainerReferences(mapping, patchContainer, resolved, raw)) + }, patch) +} diff --git a/backend/src/services/opencode-event-routing.ts b/backend/src/services/opencode-event-routing.ts index bed6f3d70..7ad667e4f 100644 --- a/backend/src/services/opencode-event-routing.ts +++ b/backend/src/services/opencode-event-routing.ts @@ -1,16 +1,27 @@ -import { EventSource } from 'eventsource' -import { openCodeLocation, sessionIDFromEvent, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' +import { + openCodeLocation, + sessionIDFromEvent, + type FormInfo, + type OpenCodeApi, + type V2Event, +} from '@opencode-manager/shared/opencode' import { logger } from '../utils/logger' -const MULTILINE_PATTERN = /[\r\n]/ +type PermissionAskedEvent = Extract +type FormCreatedEvent = Extract +type SessionStatusEvent = Extract -export type ReplayEventType = 'permission.asked' | 'form.created' | 'session.status' +export type ReplayEvent = PermissionAskedEvent | FormCreatedEvent | SessionStatusEvent -export type ReplayEventBuilder = (type: ReplayEventType, directory: string, data: unknown) => V2Event +type ReplayEventArguments = + | [type: PermissionAskedEvent['type'], directory: string, data: PermissionAskedEvent['data']] + | [type: FormCreatedEvent['type'], directory: string, data: { form: FormInfo }] + | [type: SessionStatusEvent['type'], directory: string, data: SessionStatusEvent['data']] + +export type ReplayEventBuilder = (...args: ReplayEventArguments) => ReplayEvent export interface RoutedOpenCodeEvent { event: V2Event - payloadJson: string directory: string | undefined resolvedDirectory: string | undefined } @@ -33,8 +44,6 @@ export interface SessionStatusReplayPlan { busy: string[] } -export type EventSourceInit = ConstructorParameters[1] - /** * Partitions tracked sessions into those that must be replayed as idle and the * active sessions that must be replayed as busy. @@ -52,6 +61,23 @@ export function planSessionStatusReplay( return { idle, busy } } +/** + * Builds a `session.status` replay event restating the activity state of one session. + * + * @param buildEvent Replay event builder owned by the hub that delivers the event. + * @param directory Directory the replayed event belongs to. + * @param sessionID Session whose status is replayed. + * @param status Activity state to replay. + */ +export function buildSessionStatusReplayEvent( + buildEvent: ReplayEventBuilder, + directory: string, + sessionID: string, + status: 'idle' | 'busy', +): ReplayEvent { + return buildEvent('session.status', directory, { sessionID, status: { type: status } }) +} + /** * Records the activity state an event implies for its session, so that directory-scoped work can be * limited to the sessions that are actually running. @@ -81,29 +107,31 @@ export function applySessionActivity(busySessions: Set, event: V2Event): export function createReplayEventBuilder(idPrefix: string): ReplayEventBuilder { let count = 0 - return (type, directory, data) => { + return (...args) => { count += 1 - return { - id: `${idPrefix}${count}`, - created: Date.now(), - type, - location: { directory }, - data, - } as V2Event + const base = { id: `${idPrefix}${count}`, created: Date.now() } + switch (args[0]) { + case 'permission.asked': + return { ...base, type: args[0], location: { directory: args[1] }, data: args[2] } + case 'form.created': + return { ...base, type: args[0], location: { directory: args[1] }, data: args[2] as FormCreatedEvent['data'] } + case 'session.status': + return { ...base, type: args[0], location: { directory: args[1] }, data: args[2] } + } } } -export function parseOpenCodeEvent(data: string, sessionDirectories: Map): RoutedOpenCodeEvent | null { - let event: V2Event - try { - event = JSON.parse(data) as V2Event - } catch { - return null - } - - if (!event?.type) return null - - const payloadJson = MULTILINE_PATTERN.test(data) ? JSON.stringify(event) : data +/** + * Resolves the directory a parsed event belongs to and updates the remembered session directories. + * + * @param event Parsed event received from an OpenCode server. + * @param sessionDirectories Session directories remembered across events, updated in place. + * @returns The event with its resolved directories. + */ +export function routeOpenCodeEvent( + event: V2Event, + sessionDirectories: Map, +): RoutedOpenCodeEvent { const directory = event.location?.directory const sessionID = sessionIDFromEvent(event) const forkParentId = event.type === 'session.forked' ? event.data.parentID : undefined @@ -117,7 +145,7 @@ export function parseOpenCodeEvent(data: string, sessionDirectories: Map { @@ -147,21 +175,3 @@ export async function collectPendingReplayEvents(request: PendingReplayRequest): return { permissions, forms } } - -export function createAuthorizedEventSourceInit(authorization: string | null): EventSourceInit { - if (!authorization) return undefined - return { - fetch: (input, init) => - fetch(input, { - ...init, - headers: { - ...(init?.headers ?? {}), - Authorization: authorization, - }, - }), - } -} - -export function nextBackoffDelay(currentDelayMs: number, maxDelayMs: number): number { - return Math.min(currentDelayMs * 2, maxDelayMs) -} diff --git a/backend/src/services/opencode-event-subscription.ts b/backend/src/services/opencode-event-subscription.ts new file mode 100644 index 000000000..a58d3a77b --- /dev/null +++ b/backend/src/services/opencode-event-subscription.ts @@ -0,0 +1,169 @@ +import { DEFAULTS } from '@opencode-manager/shared/config' +import type { OpenCodeApi, V2Event } from '@opencode-manager/shared/opencode' +import { logger } from '../utils/logger' + +export interface OpenCodeEventSubscriptionOptions { + api: () => OpenCodeApi + label: string + onOpen: () => void + onEvent: (event: V2Event) => void + onDisconnect: (error: unknown) => void + reconnectDelayMs?: number + maxReconnectDelayMs?: number + stallThresholdMs?: number + watchdogTickMs?: number +} + +export interface OpenCodeEventSubscription { + close: () => void + reconnect: () => void +} + +interface SubscriptionAttempt { + controller: AbortController + watchdog: ReturnType | null +} + +function nextBackoffDelay(currentDelayMs: number, maxDelayMs: number): number { + return Math.min(currentDelayMs * 2, maxDelayMs) +} + +/** + * Subscribes to an OpenCode event stream and owns its reconnect, backoff and stall detection. + * + * Every attempt yields a fresh subscription. The first `server.connected` event marks the attempt + * as established, every event reaches `onEvent` in order, and an attempt that ends, throws or + * exceeds the stall threshold disconnects, waits the current backoff delay and reconnects with the + * delay doubled up to `maxReconnectDelayMs`. + * + * @param options Stream access, callbacks and optional timing overrides. + * @returns Handles to close the subscription or force an immediate reconnect. + */ +export function subscribeOpenCodeEvents(options: OpenCodeEventSubscriptionOptions): OpenCodeEventSubscription { + const reconnectDelayMs = options.reconnectDelayMs ?? DEFAULTS.SSE.RECONNECT_DELAY_MS + const maxReconnectDelayMs = options.maxReconnectDelayMs ?? DEFAULTS.SSE.MAX_RECONNECT_DELAY_MS + const stallThresholdMs = options.stallThresholdMs ?? DEFAULTS.SSE.STALL_THRESHOLD_MS + const watchdogTickMs = options.watchdogTickMs ?? DEFAULTS.SSE.WATCHDOG_TICK_MS + + let closed = false + let currentDelay = reconnectDelayMs + let loopToken = 0 + let currentAttempt: SubscriptionAttempt | null = null + let delayTimer: ReturnType | null = null + let resolveDelay: (() => void) | null = null + + const stopAttempt = (): void => { + const attempt = currentAttempt + currentAttempt = null + if (!attempt) return + if (attempt.watchdog) { + clearInterval(attempt.watchdog) + attempt.watchdog = null + } + attempt.controller.abort() + } + + const stopDelay = (): void => { + if (delayTimer) { + clearTimeout(delayTimer) + delayTimer = null + } + const resolve = resolveDelay + resolveDelay = null + resolve?.() + } + + const wait = (delayMs: number): Promise => new Promise((resolve) => { + resolveDelay = resolve + delayTimer = setTimeout(() => { + delayTimer = null + resolveDelay = null + resolve() + }, delayMs) + }) + + const run = async (token: number): Promise => { + while (!closed && token === loopToken) { + const attempt: SubscriptionAttempt = { controller: new AbortController(), watchdog: null } + currentAttempt = attempt + let lastActivityAt = Date.now() + let opened = false + let stallError: Error | null = null + let failure: unknown + + attempt.watchdog = setInterval(() => { + if (Date.now() - lastActivityAt <= stallThresholdMs) return + stallError = new Error(`${options.label}: OpenCode event stream stalled`) + attempt.controller.abort(stallError) + }, watchdogTickMs) + + try { + const stream = options.api().event.subscribe({ + signal: attempt.controller.signal, + onActivity: () => { + lastActivityAt = Date.now() + }, + }) + + for await (const event of stream) { + if (closed || token !== loopToken) break + if (!opened && event.type === 'server.connected') { + opened = true + currentDelay = reconnectDelayMs + options.onOpen() + } + try { + options.onEvent(event) + } catch (error) { + logger.error(`${options.label}: failed to handle ${event.type} event`, error) + } + } + } catch (error) { + failure = error + } finally { + if (attempt.watchdog) { + clearInterval(attempt.watchdog) + attempt.watchdog = null + } + if (currentAttempt === attempt) currentAttempt = null + } + + if (closed || token !== loopToken) return + + try { + options.onDisconnect(stallError ?? failure) + } catch (error) { + logger.error(`${options.label}: disconnect handler failed`, error) + } + await wait(currentDelay) + if (closed || token !== loopToken) return + currentDelay = nextBackoffDelay(currentDelay, maxReconnectDelayMs) + } + } + + const startLoop = (token: number): void => { + void run(token).catch((error) => { + logger.error(`${options.label}: subscription loop failed`, error) + }) + } + + startLoop(loopToken) + + return { + close: () => { + if (closed) return + closed = true + loopToken += 1 + stopDelay() + stopAttempt() + }, + reconnect: () => { + if (closed) return + loopToken += 1 + stopDelay() + stopAttempt() + currentDelay = reconnectDelayMs + startLoop(loopToken) + }, + } +} diff --git a/backend/src/services/opencode-models.ts b/backend/src/services/opencode-models.ts index 82b3c1191..c9ab5748f 100644 --- a/backend/src/services/opencode-models.ts +++ b/backend/src/services/opencode-models.ts @@ -2,12 +2,7 @@ import type { ModelInfo, ModelRef } from '@opencode-manager/shared/opencode' import { findModelInfo, formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef, selectConfiguredModelRef, selectEffectiveModelRef } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from './opencode/client' -export interface ResolvedOpenCodeModel { - providerID: string - id: string - variant?: string - model: string -} +export type ResolvedOpenCodeModel = ModelRef & { model: string } export interface ResolveOpenCodeModelOptions { preferredModel?: string | null diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index d3381a73c..d227dbe7f 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -303,6 +303,7 @@ class OpenCodeServerManager { private openCodeClient: OpenCodeClient | null = null private sandboxEnforced: boolean = false private lifecycleInitialized: boolean = false + private readonly lifecycleOpenedListeners = new Set<() => void>() private markerRefreshTimer: ReturnType | null = null private processGeneration = 0 private intentionalStop: { pid: number; generation: number } | null = null @@ -1037,7 +1038,22 @@ class OpenCodeServerManager { } setLifecycleInitialized(initialized: boolean): void { + const opened = initialized && !this.lifecycleInitialized this.lifecycleInitialized = initialized + if (!opened) return + this.lifecycleOpenedListeners.forEach((listener) => { + try { + listener() + } catch (error) { + logger.error('OpenCode lifecycle listener failed:', error) + } + }) + } + + /** Calls `listener` each time the lifecycle gate opens, i.e. the proxy starts accepting requests. */ + onLifecycleInitialized(listener: () => void): () => void { + this.lifecycleOpenedListeners.add(listener) + return () => { this.lifecycleOpenedListeners.delete(listener) } } isLifecycleInitialized(): boolean { diff --git a/backend/src/services/opencode/client.ts b/backend/src/services/opencode/client.ts index 8dc63166a..4d89cf07d 100644 --- a/backend/src/services/opencode/client.ts +++ b/backend/src/services/opencode/client.ts @@ -1,7 +1,8 @@ import { logger } from '../../utils/logger' import { ENV } from '@opencode-manager/shared/config/env' import { createOpenCodeApi, type OpenCodeApi } from '@opencode-manager/shared/opencode' -import { filterProxyHeaders, NULL_BODY_STATUSES } from '../../utils/proxy-headers' +import { filterProxyHeaders } from '../../utils/proxy-headers' +import { forwardProxyRequest } from '../../utils/proxy-forward' import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './auth' import { getOpenCodeUpstreamBaseUrl, withDefaultOpenCodeDirectory } from './upstream' @@ -61,76 +62,36 @@ export class FetchOpenCodeClient implements OpenCodeClient { return this.fetchFn(input, { ...init, headers }) } - private async request(req: { method: string; path: string; body?: string; headers?: Record }): Promise { - const url = new URL(this.resolveBaseUrl() + req.path) - - const headers: Record = this.withDirectoryHeader({ ...(req.headers ?? {}) }) - + async forwardRaw(request: Request): Promise { + const url = new URL(request.url) + const path = url.pathname.replace(/^\/api\/opencode/, '') + url.search + const headers = this.withDirectoryHeader(filterProxyHeaders(request.headers)) const basicAuth = await this.getBasicAuth() if (basicAuth) { headers.Authorization = basicAuth } + const hasBody = request.method !== 'GET' && request.method !== 'HEAD' + try { - const response = await this.fetchFn(url, { - method: req.method, + return await forwardProxyRequest({ + url: this.resolveBaseUrl() + path, + method: request.method, headers, - body: req.body, - }) - - const filteredHeaders = filterProxyHeaders(response.headers) - - if (NULL_BODY_STATUSES.has(response.status)) { - return new Response(null, { - status: response.status, - statusText: response.statusText, - headers: filteredHeaders, - }) - } - - const body = await response.text() - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: filteredHeaders, + body: hasBody ? request.body : null, + signal: request.signal, + fetchFn: this.fetchFn, + buildResponseHeaders: (upstream) => new Headers(filterProxyHeaders(upstream)), }) } catch (error) { - logger.error(`Proxy request failed for ${req.path}:`, error) + logger.error(`Proxy request failed for ${path}:`, error) return new Response(JSON.stringify({ error: 'Proxy request failed' }), { status: 502, headers: { 'Content-Type': 'application/json' }, }) } } - - async forwardRaw(request: Request): Promise { - const url = new URL(request.url) - const cleanPathname = url.pathname.replace(/^\/api\/opencode/, '') - - if (url.pathname.includes('/permissions/')) { - logger.info(`Proxying permission request: ${url.pathname}${url.search} -> ${cleanPathname}${url.search}`) - } - - const headers: Record = {} - request.headers.forEach((value, key) => { - const lowerKey = key.toLowerCase() - if (!['host', 'connection', 'authorization'].includes(lowerKey)) { - headers[key] = value - } - }) - - const body = request.method !== 'GET' && request.method !== 'HEAD' - ? await request.text() - : undefined - - return this.request({ - method: request.method, - path: cleanPathname + url.search, - body, - headers, - }) - } } export function createOpenCodeClient( diff --git a/backend/src/services/opencode/enforcement-config.ts b/backend/src/services/opencode/enforcement-config.ts index 00fdc61bc..179a6145a 100644 --- a/backend/src/services/opencode/enforcement-config.ts +++ b/backend/src/services/opencode/enforcement-config.ts @@ -1,8 +1,6 @@ -export type EnforcementRemovedSections = Record +import { isRecord } from '@opencode-manager/shared/utils' -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} +export type EnforcementRemovedSections = Record function mergeRecordSections(target: Record, key: string, value: Record): void { const current = isRecord(target[key]) ? { ...(target[key] as Record) } : {} diff --git a/backend/src/services/remote-server-events.ts b/backend/src/services/remote-server-events.ts index c81fbcf7c..bfc500e59 100644 --- a/backend/src/services/remote-server-events.ts +++ b/backend/src/services/remote-server-events.ts @@ -1,5 +1,3 @@ -import { EventSource } from 'eventsource' -import { DEFAULTS } from '@opencode-manager/shared/config' import { buildServerDirectoryKey, LOCAL_OPENCODE_SERVER_ID, @@ -10,26 +8,23 @@ import { sessionIDFromEvent, type OpenCodeApi, type V2Event } from '@opencode-ma import { logger } from '../utils/logger' import type { OpenCodeServerChange, - OpenCodeServerConnection, OpenCodeServerRegistry, } from './opencode-server-registry' import { applySessionActivity, + buildSessionStatusReplayEvent, collectPendingReplayEvents, - createAuthorizedEventSourceInit, createReplayEventBuilder, - nextBackoffDelay, - parseOpenCodeEvent, planSessionStatusReplay, + routeOpenCodeEvent, type ReplayEventBuilder, } from './opencode-event-routing' +import { subscribeOpenCodeEvents, type OpenCodeEventSubscription } from './opencode-event-subscription' import type { RemoteReplayHandler } from './sse-aggregator' -const { RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS } = DEFAULTS.SSE - export interface RemoteEventAggregator { - deliverRemoteEnvelope(serverId: string, directory: string | null, payloadJson: string): void - deliverRemoteEnvelopeToClient(clientId: string, serverId: string, directory: string, payloadJson: string): void + deliverRemoteEnvelope(serverId: string, directory: string | null, event: V2Event): void + deliverRemoteEnvelopeToClient(clientId: string, serverId: string, directory: string, event: V2Event): void sendRemoteResync(serverId: string): void hasClientDirectory(clientId: string, key: string): boolean setRemoteReplayHandler(handler: RemoteReplayHandler | null): void @@ -38,26 +33,13 @@ export interface RemoteEventAggregator { setRemoteSubagentResolver(resolver: ((serverId: string, sessionId: string) => boolean) | null): void } -export interface RemoteEventSource { - onopen: ((event: unknown) => void) | null - onerror: ((event: unknown) => void) | null - onmessage: ((event: { data: string }) => void) | null - close(): void -} - -export type RemoteEventSourceInit = ConstructorParameters[1] -export type RemoteEventSourceFactory = (url: string, init?: RemoteEventSourceInit) => RemoteEventSource - export interface RemoteServerEventHubOptions { - eventSourceFactory?: RemoteEventSourceFactory reconnectDelayMs?: number maxReconnectDelayMs?: number } interface RemoteServerStream { - source: RemoteEventSource | null - reconnectTimeout: ReturnType | null - reconnectDelay: number + subscription: OpenCodeEventSubscription | null connected: boolean error: string | null version: string | null @@ -68,10 +50,6 @@ interface RemoteServerStream { subagentSessions: Set } -function createDefaultEventSource(url: string, init?: RemoteEventSourceInit): RemoteEventSource { - return new EventSource(url, init) as unknown as RemoteEventSource -} - function describeStreamError(error: unknown): string { if (typeof error === 'string' && error) return error if (error instanceof Error && error.message) return error.message @@ -85,9 +63,6 @@ function describeStreamError(error: unknown): string { export class RemoteServerEventHub { private readonly streams = new Map() - private readonly eventSourceFactory: RemoteEventSourceFactory - private readonly reconnectDelayMs: number - private readonly maxReconnectDelayMs: number private unsubscribeRegistry: (() => void) | null = null private started = false private versionProbeSequence = 0 @@ -96,12 +71,8 @@ export class RemoteServerEventHub { constructor( private readonly registry: OpenCodeServerRegistry, private readonly aggregator: RemoteEventAggregator, - options: RemoteServerEventHubOptions = {}, - ) { - this.eventSourceFactory = options.eventSourceFactory ?? createDefaultEventSource - this.reconnectDelayMs = options.reconnectDelayMs ?? RECONNECT_DELAY_MS - this.maxReconnectDelayMs = options.maxReconnectDelayMs ?? MAX_RECONNECT_DELAY_MS - } + private readonly options: RemoteServerEventHubOptions = {}, + ) {} start(): void { if (this.started) return @@ -191,7 +162,7 @@ export class RemoteServerEventHub { events: V2Event[], ): void { for (const event of events) { - this.aggregator.deliverRemoteEnvelopeToClient(clientId, serverId, directory, JSON.stringify(event)) + this.aggregator.deliverRemoteEnvelopeToClient(clientId, serverId, directory, event) } } @@ -214,97 +185,68 @@ export class RemoteServerEventHub { if (!this.started) return this.closeStream(serverId) - let connection: OpenCodeServerConnection try { - connection = this.registry.getConnection(serverId) + this.registry.getConnection(serverId) } catch (error) { this.recordError(serverId, describeStreamError(error)) logger.warn(`Remote event stream unavailable for ${serverId}: ${describeStreamError(error)}`) return } - const authorization = connection.authorization - const init: RemoteEventSourceInit = createAuthorizedEventSourceInit(authorization) - const stream = this.ensureStream(serverId) - let source: RemoteEventSource - try { - source = this.eventSourceFactory(`${connection.baseUrl}/api/event`, init) - } catch (error) { - this.recordError(serverId, describeStreamError(error)) - this.scheduleReconnect(serverId) - return - } - - stream.source = source stream.error = null - source.onopen = () => this.handleOpen(serverId, source) - source.onerror = (event) => this.handleStreamError(serverId, source, event) - source.onmessage = (event) => this.handleMessage(serverId, event.data) + + const subscription = subscribeOpenCodeEvents({ + api: () => this.registry.getClient(serverId).api, + label: serverId, + onOpen: () => this.handleOpen(serverId, subscription), + onEvent: (event) => this.handleMessage(serverId, event), + onDisconnect: (error) => this.handleStreamError(serverId, error), + reconnectDelayMs: this.options.reconnectDelayMs, + maxReconnectDelayMs: this.options.maxReconnectDelayMs, + }) + + stream.subscription = subscription logger.info(`Remote event stream connecting: ${serverId}`) } - private handleOpen(serverId: string, source: RemoteEventSource): void { + private handleOpen(serverId: string, subscription: OpenCodeEventSubscription): void { const stream = this.streams.get(serverId) - if (!stream || stream.source !== source) return + if (!stream || stream.subscription !== subscription) return stream.connected = true stream.error = null - stream.reconnectDelay = this.reconnectDelayMs logger.info(`Remote event stream connected: ${serverId}`) this.aggregator.notifyServerConnected(serverId) this.aggregator.sendRemoteResync(serverId) void this.refreshVersion(serverId) - void this.replaySessionStatuses(serverId, source) + void this.replaySessionStatuses(serverId, subscription) } - private handleStreamError(serverId: string, source: RemoteEventSource, error: unknown): void { + private handleStreamError(serverId: string, error: unknown): void { const stream = this.streams.get(serverId) - if (!stream || stream.source !== source) return + if (!stream) return stream.connected = false stream.error = describeStreamError(error) logger.warn(`Remote event stream error for ${serverId}: ${stream.error}`) - this.closeSource(stream) - this.scheduleReconnect(serverId) - } - - private scheduleReconnect(serverId: string): void { - const stream = this.streams.get(serverId) - if (!this.started || !stream || stream.reconnectTimeout) return - - const delay = stream.reconnectDelay - stream.reconnectTimeout = setTimeout(() => { - const current = this.streams.get(serverId) - if (!current) return - current.reconnectTimeout = null - current.reconnectDelay = nextBackoffDelay(current.reconnectDelay, this.maxReconnectDelayMs) - this.connectServer(serverId) - }, delay) } - private handleMessage(serverId: string, data: string): void { + private handleMessage(serverId: string, event: V2Event): void { const stream = this.streams.get(serverId) if (!stream) return - const routed = parseOpenCodeEvent(data, stream.sessionDirectories) - if (!routed) return - - try { - const { event, resolvedDirectory, payloadJson } = routed - const sessionID = sessionIDFromEvent(event) - if (sessionID) applySessionActivity(stream.busySessions, event) - if (sessionID && event.type === 'session.deleted') { - stream.subagentSessions.delete(sessionID) - } else if (event.type === 'session.created' && event.data.parentID) { - stream.subagentSessions.add(event.data.sessionID) - } - - this.aggregator.notifyServerEvent(serverId, resolvedDirectory ?? null, event) - this.aggregator.deliverRemoteEnvelope(serverId, resolvedDirectory ?? null, payloadJson) - } catch (error) { - logger.error(`Remote event stream failed to handle ${routed.event.type} for ${serverId}:`, error) + const { resolvedDirectory } = routeOpenCodeEvent(event, stream.sessionDirectories) + const sessionID = sessionIDFromEvent(event) + if (sessionID) applySessionActivity(stream.busySessions, event) + if (sessionID && event.type === 'session.deleted') { + stream.subagentSessions.delete(sessionID) + } else if (event.type === 'session.created' && event.data.parentID) { + stream.subagentSessions.add(event.data.sessionID) } + + this.aggregator.notifyServerEvent(serverId, resolvedDirectory ?? null, event) + this.aggregator.deliverRemoteEnvelope(serverId, resolvedDirectory ?? null, event) } private handleRegistryChange(change: OpenCodeServerChange): void { @@ -331,7 +273,6 @@ export class RemoteServerEventHub { const stream = this.streams.get(change.serverId) if (stream) { - stream.reconnectDelay = this.reconnectDelayMs stream.version = null stream.versionRequested = false stream.versionProbeToken = 0 @@ -339,13 +280,17 @@ export class RemoteServerEventHub { this.connectServer(change.serverId) } - private isStreamCurrent(serverId: string, stream: RemoteServerStream, source: RemoteEventSource): boolean { - return this.streams.get(serverId) === stream && stream.source === source && stream.connected + private isStreamCurrent( + serverId: string, + stream: RemoteServerStream, + subscription: OpenCodeEventSubscription, + ): boolean { + return this.streams.get(serverId) === stream && stream.subscription === subscription && stream.connected } - private async replaySessionStatuses(serverId: string, source: RemoteEventSource): Promise { + private async replaySessionStatuses(serverId: string, subscription: OpenCodeEventSubscription): Promise { const stream = this.streams.get(serverId) - if (!stream || !this.isStreamCurrent(serverId, stream, source)) return + if (!stream || !this.isStreamCurrent(serverId, stream, subscription)) return let api: OpenCodeApi try { @@ -363,25 +308,25 @@ export class RemoteServerEventHub { return } - if (!this.isStreamCurrent(serverId, stream, source)) return + if (!this.isStreamCurrent(serverId, stream, subscription)) return await this.resolveActiveSessionDirectories(api, serverId, stream, Object.keys(active ?? {})) - if (!this.isStreamCurrent(serverId, stream, source)) return + if (!this.isStreamCurrent(serverId, stream, subscription)) return const plan = planSessionStatusReplay(active, stream.sessionDirectories) let replayed = 0 for (const { sessionID, directory } of plan.idle) { - const event = this.buildReplayEvent('session.status', directory, { sessionID, status: { type: 'idle' } }) - this.aggregator.deliverRemoteEnvelope(serverId, directory, JSON.stringify(event)) + const event = buildSessionStatusReplayEvent(this.buildReplayEvent, directory, sessionID, 'idle') + this.aggregator.deliverRemoteEnvelope(serverId, directory, event) stream.busySessions.delete(sessionID) replayed++ } for (const sessionID of plan.busy) { const directory = stream.sessionDirectories.get(sessionID) ?? null - const event = this.buildReplayEvent('session.status', directory ?? '', { sessionID, status: { type: 'busy' } }) - this.aggregator.deliverRemoteEnvelope(serverId, directory, JSON.stringify(event)) + const event = buildSessionStatusReplayEvent(this.buildReplayEvent, directory ?? '', sessionID, 'busy') + this.aggregator.deliverRemoteEnvelope(serverId, directory, event) stream.busySessions.add(sessionID) replayed++ } @@ -442,9 +387,7 @@ export class RemoteServerEventHub { if (existing) return existing const stream: RemoteServerStream = { - source: null, - reconnectTimeout: null, - reconnectDelay: this.reconnectDelayMs, + subscription: null, connected: false, error: null, version: null, @@ -464,20 +407,16 @@ export class RemoteServerEventHub { stream.error = error } - private closeSource(stream: RemoteServerStream): void { - if (!stream.source) return - stream.source.close() - stream.source = null + private closeSubscription(stream: RemoteServerStream): void { + if (!stream.subscription) return + stream.subscription.close() + stream.subscription = null } private closeStream(serverId: string): void { const stream = this.streams.get(serverId) if (!stream) return - if (stream.reconnectTimeout) { - clearTimeout(stream.reconnectTimeout) - stream.reconnectTimeout = null - } - this.closeSource(stream) + this.closeSubscription(stream) stream.connected = false } diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 5e75e9d59..a66308780 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -1,20 +1,17 @@ -import { EventSource } from 'eventsource' import { logger } from '../utils/logger' -import { DEFAULTS } from '@opencode-manager/shared/config' import { buildServerDirectoryKey, LOCAL_OPENCODE_SERVER_ID, parseServerDirectoryKey } from '@opencode-manager/shared/schemas' -import { type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' -import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './opencode/auth' -import { getOpenCodeUpstreamBaseUrl } from './opencode/upstream' +import { type V2Event } from '@opencode-manager/shared/opencode' +import type { OpenCodeClient } from './opencode/client' import { encodeSSEFrame } from '../utils/sse-frame' import { + buildSessionStatusReplayEvent, collectPendingReplayEvents, - createAuthorizedEventSourceInit, createReplayEventBuilder, - nextBackoffDelay, - parseOpenCodeEvent, planSessionStatusReplay, + routeOpenCodeEvent, type ReplayEventBuilder, } from './opencode-event-routing' +import { subscribeOpenCodeEvents, type OpenCodeEventSubscription } from './opencode-event-subscription' type SSEClientCallback = (event: string, data: string) => void type SSEClientFrameWriter = (frame: Uint8Array) => void @@ -37,16 +34,11 @@ interface SSEClient { export type SSEEvent = V2Event -export interface PendingActionsFetcher { - api: OpenCodeApi -} - export interface ScheduledSessionRef { sessionID: string directory: string } -const { RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS } = DEFAULTS.SSE const LOCATION_CATALOG_EVENT_TYPES = new Set(['command.updated', 'agent.updated', 'skill.updated', 'model.updated', 'provider.updated', 'integration.updated']) function serializeEnvelope(directory: string | null, payloadJson: string, serverId?: string): string { @@ -66,26 +58,21 @@ class SSEAggregator { private serverConnectedListeners: Set = new Set() private remoteSubagentResolver: RemoteSubagentResolver | null = null private subagentSessions: Map> = new Map() - private upstream: EventSource | null = null - private reconnectTimeout: ReturnType | null = null - private reconnectDelay: number = RECONNECT_DELAY_MS + private subscription: OpenCodeEventSubscription | null = null private upstreamConnected = false private everConnected = false private started = false - private pendingActionsFetcher: PendingActionsFetcher | null = null - private passwordResolver: OpenCodePasswordResolver | null = null + private openCodeClient: Pick | null = null private scheduledSessionsResolver: (() => ScheduledSessionRef[]) | null = null private remoteReplayHandler: RemoteReplayHandler | null = null + private isResyncGateOpen: () => boolean = () => true + private resyncPending = false private readonly buildReplayEvent: ReplayEventBuilder = createReplayEventBuilder('ocm_replay_') private constructor() {} - setPendingActionsFetcher(fetcher: PendingActionsFetcher | null): void { - this.pendingActionsFetcher = fetcher - } - - setPasswordResolver(resolver: OpenCodePasswordResolver | null): void { - this.passwordResolver = resolver + setOpenCodeClient(client: Pick | null): void { + this.openCodeClient = client } setScheduledSessionsResolver(resolver: () => ScheduledSessionRef[]): void { @@ -96,15 +83,26 @@ class SSEAggregator { this.remoteReplayHandler = handler } + /** + * Holds the upstream `resync` while the gate is closed, so clients do not re-read OpenCode + * state through the proxy before it accepts requests. Call {@link releasePendingResync} when + * the gate opens. + */ + setResyncGate(isOpen: () => boolean): void { + this.isResyncGateOpen = isOpen + } + + /** Broadcasts a `resync` held by the gate while the upstream stream is still connected. */ + releasePendingResync(): void { + if (!this.resyncPending || !this.upstreamConnected) return + this.resyncPending = false + this.broadcastResync() + } + reconnect(): void { if (!this.started) return logger.info('SSE forcing upstream reconnect (auth changed)') - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout) - this.reconnectTimeout = null - } - this.reconnectDelay = RECONNECT_DELAY_MS - void this.connectUpstream() + this.subscription?.reconnect() } static getInstance(): SSEAggregator { @@ -116,8 +114,27 @@ class SSEAggregator { start(): void { if (this.started) return + const client = this.openCodeClient + if (!client) { + logger.warn('SSE aggregator cannot start without an OpenCode client') + return + } this.started = true - void this.connectUpstream() + this.subscription = subscribeOpenCodeEvents({ + api: () => client.api, + label: 'SSE aggregator', + onOpen: () => { + this.handleUpstreamOpen(this.everConnected) + }, + onEvent: (event) => { + this.handleUpstreamEvent(event) + }, + onDisconnect: (error) => { + this.upstreamConnected = false + this.resyncPending = false + logger.warn(`SSE upstream disconnected: ${String(error)}`) + }, + }) } addClient(id: string, callback: SSEClientCallback, writeFrame: SSEClientFrameWriter, directories: string[]): () => void { @@ -250,21 +267,21 @@ class SSEAggregator { } private async replayPendingActionsForClient(clientId: string, directories: string[]): Promise { - const fetcher = this.pendingActionsFetcher - if (!fetcher) return + const client = this.openCodeClient + if (!client) return await Promise.allSettled(directories.map(directory => - this.replayPendingActionsForDirectory(clientId, directory, fetcher) + this.replayPendingActionsForDirectory(clientId, directory, client) )) } private async replayPendingActionsForDirectory( clientId: string, directory: string, - fetcher: PendingActionsFetcher, + client: Pick, ): Promise { const events = await collectPendingReplayEvents({ - api: fetcher.api, + api: client.api, directory, buildEvent: this.buildReplayEvent, logScope: 'replay', @@ -294,12 +311,12 @@ class SSEAggregator { } private async replaySessionStatusesForTrackedDirectories(): Promise { - const fetcher = this.pendingActionsFetcher - if (!fetcher) return + const client = this.openCodeClient + if (!client) return let running: Record try { - running = await fetcher.api.session.active() + running = await client.api.session.active() } catch (error) { logger.warn(`replay: failed to fetch active sessions: ${String(error)}`) return @@ -311,18 +328,18 @@ class SSEAggregator { let replayed = 0 for (const { sessionID, directory } of plan.idle) { - this.deliverEvent(directory, this.buildReplayEvent('session.status', directory, { sessionID, status: { type: 'idle' } })) + this.deliverEvent(directory, buildSessionStatusReplayEvent(this.buildReplayEvent, directory, sessionID, 'idle')) replayed++ } const runningDirectories = await Promise.all(plan.busy.map(async (sessionID) => ({ sessionID, - directory: tracked.get(sessionID) ?? await this.resolveSessionDirectory(fetcher, sessionID), + directory: tracked.get(sessionID) ?? await this.resolveSessionDirectory(client, sessionID), }))) for (const { sessionID, directory } of runningDirectories) { if (!directory) continue - this.deliverEvent(directory, this.buildReplayEvent('session.status', directory, { sessionID, status: { type: 'busy' } })) + this.deliverEvent(directory, buildSessionStatusReplayEvent(this.buildReplayEvent, directory, sessionID, 'busy')) replayed++ } @@ -343,9 +360,9 @@ class SSEAggregator { return tracked } - private async resolveSessionDirectory(fetcher: PendingActionsFetcher, sessionID: string): Promise { + private async resolveSessionDirectory(client: Pick, sessionID: string): Promise { try { - const session = await fetcher.api.session.get({ sessionID }) + const session = await client.api.session.get({ sessionID }) this.sessionDirectories.set(sessionID, session.location.directory) return session.location.directory } catch (error) { @@ -354,56 +371,16 @@ class SSEAggregator { } } - private async connectUpstream(): Promise { - if (!this.started) return - if (this.upstream) { - this.upstream.close() - this.upstream = null - } - - const url = `${getOpenCodeUpstreamBaseUrl()}/api/event` - const wasConnectedBefore = this.everConnected - logger.info(`SSE connecting to OpenCode global stream: ${url}`) - - const authHeader = this.passwordResolver - ? await getOpenCodeBasicAuthHeader(this.passwordResolver) - : null - - if (!this.started) return - - const init: ConstructorParameters[1] = createAuthorizedEventSourceInit(authHeader) - - const es = new EventSource(url, init) - this.upstream = es - - es.onopen = () => { - this.handleUpstreamOpen(wasConnectedBefore) - } - - es.onerror = (event) => { - this.upstreamConnected = false - if (es === this.upstream) { - const code = (event as { code?: number }).code - const message = (event as { message?: string }).message - logger.warn(`SSE upstream error${code ? ` (code=${code})` : ''}${message ? `: ${message}` : ''}`) - es.close() - this.upstream = null - this.scheduleReconnect() - } - } - - es.onmessage = (event) => { - this.handleUpstreamMessage(event.data) - } - } - private handleUpstreamOpen(wasConnectedBefore: boolean): void { logger.info('SSE global stream connected') this.upstreamConnected = true - this.reconnectDelay = RECONNECT_DELAY_MS this.everConnected = true this.notifyUpstreamConnected() - this.broadcastResync() + if (this.isResyncGateOpen()) { + this.broadcastResync() + } else { + this.resyncPending = true + } if (wasConnectedBefore) { void this.replaySessionStatusesForTrackedDirectories() } @@ -420,15 +397,6 @@ class SSEAggregator { this.broadcastToAll('resync', JSON.stringify({ timestamp: Date.now() })) } - private scheduleReconnect(): void { - if (!this.started || this.reconnectTimeout) return - this.reconnectTimeout = setTimeout(() => { - this.reconnectTimeout = null - this.reconnectDelay = nextBackoffDelay(this.reconnectDelay, MAX_RECONNECT_DELAY_MS) - void this.connectUpstream() - }, this.reconnectDelay) - } - onEvent(listener: SSEEventListener): () => void { this.eventListeners.add(listener) return () => { this.eventListeners.delete(listener) } @@ -465,46 +433,39 @@ class SSEAggregator { this.remoteSubagentResolver = resolver } - private handleUpstreamMessage(data: string): void { - const routed = parseOpenCodeEvent(data, this.sessionDirectories) - if (!routed) return - - const { event, payloadJson, directory, resolvedDirectory } = routed + private handleUpstreamEvent(event: V2Event): void { + const { directory, resolvedDirectory } = routeOpenCodeEvent(event, this.sessionDirectories) - try { - if (directory) { - if (LOCATION_CATALOG_EVENT_TYPES.has(event.type)) { - this.deliverResolvedEvent(directory, event, payloadJson) - } else { - this.deliverEvent(directory, event, payloadJson) - } - return - } - - if (resolvedDirectory) { - this.deliverResolvedEvent(resolvedDirectory, event, payloadJson) - return + if (directory) { + if (LOCATION_CATALOG_EVENT_TYPES.has(event.type)) { + this.deliverResolvedEvent(directory, event) + } else { + this.deliverEvent(directory, event) } + return + } - this.notifyListeners(null, event) - this.writeEnvelopeToClients(this.clients.keys(), null, payloadJson) - } catch (error) { - logger.error(`SSE failed to handle ${event.type} event:`, error) + if (resolvedDirectory) { + this.deliverResolvedEvent(resolvedDirectory, event) + return } + + this.notifyListeners(null, event) + this.writeEnvelopeToClients(this.clients.keys(), null, event) } - private deliverEvent(directory: string, event: SSEEvent, payloadJson?: string): void { + private deliverEvent(directory: string, event: SSEEvent): void { this.notifyEvent(directory, event) const subscriberIds = this.directoryClients.get(directory) if (!subscriberIds || subscriberIds.size === 0) return - this.writeEnvelopeToClients(subscriberIds, directory, payloadJson ?? JSON.stringify(event)) + this.writeEnvelopeToClients(subscriberIds, directory, event) } - private deliverResolvedEvent(directory: string, event: SSEEvent, payloadJson: string): void { + private deliverResolvedEvent(directory: string, event: SSEEvent): void { this.notifyEvent(directory, event) - this.writeEnvelopeToClients(this.clients.keys(), null, payloadJson) + this.writeEnvelopeToClients(this.clients.keys(), null, event) } private notifyEvent(directory: string, event: SSEEvent): void { @@ -522,14 +483,14 @@ class SSEAggregator { private writeEnvelopeToClients( clientIds: Iterable, directory: string | null, - payloadJson: string, + event: SSEEvent, serverId?: string, ): void { let frame: Uint8Array | null = null for (const clientId of clientIds) { const client = this.clients.get(clientId) if (!client) continue - frame ??= encodeSSEFrame('message', serializeEnvelope(directory, payloadJson, serverId)) + frame ??= encodeSSEFrame('message', serializeEnvelope(directory, JSON.stringify(event), serverId)) this.writeFrame(client, frame, 'Failed to send to client') } } @@ -546,15 +507,15 @@ class SSEAggregator { return this.clients.get(clientId)?.directories.has(key) ?? false } - deliverRemoteEnvelope(serverId: string, directory: string | null, payloadJson: string): void { - this.writeEnvelopeToClients(this.clients.keys(), directory, payloadJson, serverId) + deliverRemoteEnvelope(serverId: string, directory: string | null, event: SSEEvent): void { + this.writeEnvelopeToClients(this.clients.keys(), directory, event, serverId) } - deliverRemoteEnvelopeToClient(clientId: string, serverId: string, directory: string, payloadJson: string): void { + deliverRemoteEnvelopeToClient(clientId: string, serverId: string, directory: string, event: SSEEvent): void { if (!this.hasClientDirectory(clientId, buildServerDirectoryKey(serverId, directory))) return const client = this.clients.get(clientId) if (!client) return - const frame = encodeSSEFrame('message', serializeEnvelope(directory, payloadJson, serverId)) + const frame = encodeSSEFrame('message', serializeEnvelope(directory, JSON.stringify(event), serverId)) this.writeFrame(client, frame, 'Failed to send remote event to client') } @@ -616,6 +577,7 @@ class SSEAggregator { } case 'session.deleted': { this.unmarkSubagentSession(directory, event.data.sessionID) + this.markSessionIdle(directory, event.data.sessionID) return } } @@ -731,16 +693,12 @@ class SSEAggregator { shutdown(): void { this.started = false + this.everConnected = false - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout) - this.reconnectTimeout = null - } - if (this.upstream) { - this.upstream.close() - this.upstream = null - } + this.subscription?.close() + this.subscription = null this.upstreamConnected = false + this.resyncPending = false this.activeSessions.clear() this.sessionDirectories.clear() diff --git a/backend/src/utils/proxy-forward.ts b/backend/src/utils/proxy-forward.ts new file mode 100644 index 000000000..55a70a095 --- /dev/null +++ b/backend/src/utils/proxy-forward.ts @@ -0,0 +1,79 @@ +import { NULL_BODY_STATUSES } from './proxy-headers' + +export class ProxyHeadersTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Upstream did not send response headers within ${timeoutMs}ms`) + this.name = 'ProxyHeadersTimeoutError' + } +} + +export interface ForwardProxyRequestInput { + url: string | URL + method: string + headers: Record + body: RequestInit['body'] + signal?: AbortSignal + headersTimeoutMs?: number + fetchFn?: typeof fetch + buildResponseHeaders: (upstream: Headers) => Headers +} + +/** + * Forwards one HTTP request to an upstream server and returns its response with the + * body left unread so it keeps streaming to the caller. + * + * When `headersTimeoutMs` is set, requests without a body are aborted if the upstream + * does not send response headers in time; that abort surfaces as a + * `ProxyHeadersTimeoutError`. Every other fetch failure is rethrown unchanged. + */ +export async function forwardProxyRequest({ + url, + method, + headers, + body, + signal, + headersTimeoutMs, + fetchFn = fetch, + buildResponseHeaders, +}: ForwardProxyRequestInput): Promise { + const timeoutMs = body === null || body === undefined ? headersTimeoutMs : undefined + const timeoutController = timeoutMs === undefined ? null : new AbortController() + let headersTimedOut = false + const timeoutTimer = timeoutController === null + ? null + : setTimeout(() => { + headersTimedOut = true + timeoutController.abort() + }, timeoutMs) + + const requestSignal = timeoutController === null + ? signal + : signal + ? AbortSignal.any([signal, timeoutController.signal]) + : timeoutController.signal + + let upstream: Response + try { + upstream = await fetchFn(url, { + method, + headers, + body: body ?? undefined, + redirect: 'manual', + duplex: 'half', + signal: requestSignal, + }) + } catch (error) { + if (headersTimedOut && timeoutMs !== undefined) { + throw new ProxyHeadersTimeoutError(timeoutMs) + } + throw error + } finally { + if (timeoutTimer !== null) clearTimeout(timeoutTimer) + } + + return new Response(NULL_BODY_STATUSES.has(upstream.status) ? null : upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: buildResponseHeaders(upstream.headers), + }) +} diff --git a/backend/test/index.test.ts b/backend/test/index.test.ts index 2772bab6f..842056577 100644 --- a/backend/test/index.test.ts +++ b/backend/test/index.test.ts @@ -125,8 +125,9 @@ const sseAggregatorMock = vi.hoisted(() => ({ onServerEvent: vi.fn(), onUpstreamConnected: vi.fn(), onServerConnected: vi.fn(), - setPendingActionsFetcher: vi.fn(), - setPasswordResolver: vi.fn(), + setOpenCodeClient: vi.fn(), + setResyncGate: vi.fn(), + releasePendingResync: vi.fn(), setScheduledSessionsResolver: vi.fn(), setRemoteReplayHandler: vi.fn(), setRemoteSubagentResolver: vi.fn(), @@ -154,6 +155,8 @@ const serverManagerMock = vi.hoisted(() => ({ isRestartPending: vi.fn().mockReturnValue(false), restart: vi.fn().mockResolvedValue(undefined), checkHealth: vi.fn().mockResolvedValue(true), + isLifecycleInitialized: vi.fn().mockReturnValue(false), + onLifecycleInitialized: vi.fn(), })) vi.mock('../src/services/opencode-single-server', async (importOriginal) => { @@ -277,6 +280,18 @@ describe('backend entrypoint', () => { expect(seedOpenCodeConfigFileMock).toHaveBeenCalledTimes(1) }) + it('holds the upstream resync until the OpenCode lifecycle gate opens', async () => { + await import('../src/index') + + const isGateOpen = sseAggregatorMock.setResyncGate.mock.calls[0]![0] as () => boolean + serverManagerMock.isLifecycleInitialized.mockReturnValueOnce(true) + expect(isGateOpen()).toBe(true) + + const onGateOpen = serverManagerMock.onLifecycleInitialized.mock.calls[0]![0] as () => void + onGateOpen() + expect(sseAggregatorMock.releasePendingResync).toHaveBeenCalledTimes(1) + }) + it('answers the root route with service metadata outside production', async () => { await import('../src/index') diff --git a/backend/test/routes/internal-opencode-config.test.ts b/backend/test/routes/internal-opencode-config.test.ts index 5cd6307c4..59ea2e35d 100644 --- a/backend/test/routes/internal-opencode-config.test.ts +++ b/backend/test/routes/internal-opencode-config.test.ts @@ -253,7 +253,7 @@ describe('internal/opencode-config routes', () => { expect(body.actualRevision).not.toBe(initial.revision!) }) - it('GET /api/internal/opencode-config/mcp lists configured servers with redacted config and live status', async () => { + it('GET /api/internal/opencode-config/mcp lists managed servers with live status and no shape', async () => { await writeOpenCodeConfigFile( JSON.stringify({ mcp: { @@ -265,11 +265,24 @@ describe('internal/opencode-config routes', () => { }, local: { type: 'local', command: ['npx', 'local'], disabled: true }, }, - legacy: { type: 'local', command: ['npx', 'legacy'], enabled: false }, }, }), 'opencode.jsonc', ) + configGetMock.mockImplementation(() => Promise.resolve([ + { + type: 'document', + path: configPath('opencode.jsonc'), + info: { + mcp: { + servers: { + linear: { type: 'remote', url: 'https://linear.example.com' }, + local: { type: 'local', command: ['npx', 'local'], disabled: true }, + }, + }, + }, + }, + ])) mcpListMock.mockResolvedValue({ data: [ { name: 'linear', status: { status: 'connected' } }, @@ -281,31 +294,37 @@ describe('internal/opencode-config routes', () => { expect(res.status).toBe(200) const body = await res.json() as { - revision: string | null + revision: string servers: Array<{ name: string type: string command?: string[] url?: string enabled: boolean - shape: string status?: string }> } expect(body.revision).toMatch(/^[a-f0-9]{64}$/) expect(body.servers).toEqual([ - { name: 'legacy', type: 'local', command: ['npx', 'legacy'], enabled: false, shape: 'legacy' }, - { name: 'linear', type: 'remote', url: 'https://linear.example.com', enabled: true, shape: 'servers', status: 'connected' }, - { name: 'local', type: 'local', command: ['npx', 'local'], enabled: false, shape: 'servers', status: 'disabled' }, + { name: 'linear', type: 'remote', url: 'https://linear.example.com', enabled: true, status: 'connected' }, + { name: 'local', type: 'local', command: ['npx', 'local'], enabled: false, status: 'disabled' }, ]) + expect(body.servers.every((server) => !('shape' in server))).toBe(true) expect(mcpListMock).toHaveBeenCalledWith({ location: { directory: ws.workspacePath } }) }) - it('GET /api/internal/opencode-config/mcp returns config-only when the server is unreachable', async () => { + it('GET /api/internal/opencode-config/mcp returns managed servers when the status list is unreachable', async () => { await writeOpenCodeConfigFile( JSON.stringify({ mcp: { servers: { local: { type: 'local', command: ['npx', 'local'] } } } }), 'opencode.jsonc', ) + configGetMock.mockImplementation(() => Promise.resolve([ + { + type: 'document', + path: configPath('opencode.jsonc'), + info: { mcp: { servers: { local: { type: 'local', command: ['npx', 'local'] } } } }, + }, + ])) mcpListMock.mockRejectedValue(new Error('unreachable')) const res = await app.request('/api/internal/opencode-config/mcp', { headers: authHeaders() }) @@ -313,10 +332,20 @@ describe('internal/opencode-config routes', () => { expect(res.status).toBe(200) const body = await res.json() as { servers: Array> } expect(body.servers).toEqual([ - { name: 'local', type: 'local', command: ['npx', 'local'], enabled: true, shape: 'servers' }, + { name: 'local', type: 'local', command: ['npx', 'local'], enabled: true }, ]) }) + it('GET /api/internal/opencode-config/mcp returns 503 when OpenCode is unreachable', async () => { + configGetMock.mockImplementation(() => Promise.reject(new ClientError('Transport'))) + + const res = await app.request('/api/internal/opencode-config/mcp', { headers: authHeaders() }) + + expect(res.status).toBe(503) + const body = await res.json() as { error: string } + expect(body.error).toBe('OpenCode server unavailable') + }) + it('PUT /api/internal/opencode-config writes the file and applies it through a location reload without a restart', async () => { await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED, 'opencode.jsonc') @@ -415,15 +444,15 @@ describe('internal/opencode-config routes', () => { }) it('PUT /api/internal/opencode-config returns 409 for a shadowed removal', async () => { - const lower = '{"theme":"light","model":"a"}' - const target = '{"model":"b"}' + const lower = '{"theme":"light","model":"test/a"}' + const target = '{"model":"test/b"}' await writeOpenCodeConfigFile(target, 'opencode.jsonc') await writeFile(configPath('opencode.json'), lower, 'utf8') const res = await app.request('/api/internal/opencode-config', { method: 'PUT', headers: { 'content-type': 'application/json', ...authHeaders() }, - body: JSON.stringify({ content: { model: 'b' } }), + body: JSON.stringify({ content: { model: 'test/b' } }), }) expect(res.status).toBe(409) @@ -445,6 +474,24 @@ describe('internal/opencode-config routes', () => { }) expect(res.status).toBe(400) + const body = await res.json() as { error: string; validationIssues: Array<{ path: string; message: string }> } + expect(body.error).toBe('Invalid config data') + expect(body.validationIssues.map((issue) => issue.path)).toEqual(['model']) + }) + + it('PUT /api/internal/opencode-config returns 400 with validationIssues for invalid raw content', async () => { + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED, 'opencode.jsonc') + + const res = await app.request('/api/internal/opencode-config', { + method: 'PUT', + headers: { 'content-type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ content: '{"model": 5}' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string; validationIssues: Array<{ path: string; message: string }> } + expect(body.error).toBe('Invalid config data') + expect(body.validationIssues.map((issue) => issue.path)).toEqual(['model']) }) it('PUT /api/internal/opencode-config returns 400 for an invalid JSON body', async () => { @@ -508,4 +555,75 @@ describe('internal/opencode-config routes', () => { const body = await res.json() as { error: string } expect(body.error).toBe('Failed to get effective OpenCode config') }) + + it('GET /api/internal/opencode-config/managed merges only Manager source documents and redacts secrets', async () => { + await writeOpenCodeConfigFile(JSON.stringify({ theme: 'light' }), 'opencode.json') + await writeOpenCodeConfigFile(JSON.stringify({ theme: 'dark' }), 'opencode.jsonc') + configGetMock.mockImplementation(() => Promise.resolve([ + { + type: 'document', + path: configPath('opencode.json'), + info: { theme: 'light', providers: { example: { apiKey: 'secret-key' } } }, + }, + { + type: 'document', + path: configPath('opencode.jsonc'), + info: { + theme: 'dark', + mcp: { servers: { local: { type: 'local', command: ['npx', 'local'] } } }, + }, + }, + { + type: 'document', + path: path.join(ws.workspacePath, 'unmanaged', 'opencode.json'), + info: { theme: 'ignored' }, + }, + { type: 'directory', path: path.join(ws.workspacePath, '.config', 'opencode') }, + ])) + + const res = await app.request('/api/internal/opencode-config/managed', { headers: authHeaders() }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + info: { + theme: 'dark', + providers: { example: { apiKey: '' } }, + mcp: { servers: { local: { type: 'local', command: ['npx', 'local'] } } }, + }, + }) + expect(configGetMock).toHaveBeenCalledWith({ location: { directory: ws.workspacePath } }) + }) + + it('GET /api/internal/opencode-config/managed reports a v1-only file through the converted entries the client returns', async () => { + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED, 'opencode.json') + await writeFile(configPath('opencode.json'), JSON.stringify({ + agent: { reviewer: { mode: 'subagent' } }, + }), 'utf8') + configGetMock.mockImplementation(() => Promise.resolve([ + { + type: 'document', + path: configPath('opencode.json'), + info: { agents: { reviewer: { mode: 'subagent' } } }, + }, + ])) + + const res = await app.request('/api/internal/opencode-config/managed', { headers: authHeaders() }) + + expect(res.status).toBe(200) + const body = await res.json() as { info: Record } + expect(body.info).toEqual({ agents: { reviewer: { mode: 'subagent' } } }) + const persisted = JSON.parse(await readFile(configPath('opencode.json'), 'utf8')) as Record + expect(persisted.agent).toEqual({ reviewer: { mode: 'subagent' } }) + expect((await readOpenCodeConfigFile())?.path).toBe(configPath('opencode.json')) + }) + + it('GET /api/internal/opencode-config/managed returns 503 when OpenCode is unreachable', async () => { + configGetMock.mockImplementation(() => Promise.reject(new ClientError('Transport'))) + + const res = await app.request('/api/internal/opencode-config/managed', { headers: authHeaders() }) + + expect(res.status).toBe(503) + const body = await res.json() as { error: string } + expect(body.error).toBe('OpenCode server unavailable') + }) }) diff --git a/backend/test/routes/opencode-auth-proxy.test.ts b/backend/test/routes/opencode-auth-proxy.test.ts index 40d1afa35..fa550fae6 100644 --- a/backend/test/routes/opencode-auth-proxy.test.ts +++ b/backend/test/routes/opencode-auth-proxy.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { Hono } from 'hono' import type { MiddlewareHandler } from 'hono' import { createAuthenticatedOpenCodeProxyRoutes } from '../../src/routes/opencode-auth-proxy' -import type { OpenCodeClient } from '../../src/services/opencode/client' +import { FetchOpenCodeClient, type OpenCodeClient } from '../../src/services/opencode/client' import { OpenCodeSupervisor } from '../../src/services/opencode-supervisor' import type { SettingsService } from '../../src/services/settings' @@ -14,7 +14,8 @@ vi.mock('../../src/services/opencode-single-server', () => ({ const forwardRawMock = vi.hoisted(() => vi.fn(async () => new Response('ok', { status: 200 }))) -vi.mock('../../src/services/opencode/client', () => ({ +vi.mock('../../src/services/opencode/client', async (importOriginal) => ({ + ...(await importOriginal()), createOpenCodeClient: vi.fn(), })) @@ -344,3 +345,54 @@ describe('authenticated opencode proxy routes', () => { expect(forwarded).toEqual(JSON.parse(body)) }) }) + +describe('authenticated opencode proxy streaming', () => { + it('streams the upstream body chunk by chunk while forwarding the request body as a stream', async () => { + const encoder = new TextEncoder() + const decoder = new TextDecoder() + let upstreamController!: ReadableStreamDefaultController + const upstreamBody = new ReadableStream({ + start(controller) { + upstreamController = controller + }, + }) + + let capturedInit: RequestInit | undefined + const fetchFn = async (_input: URL | Request | string, init?: RequestInit) => { + capturedInit = init + return new Response(upstreamBody, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }) + } + const client = new FetchOpenCodeClient({ + baseUrl: 'http://127.0.0.1:5551', + basicAuth: '', + fetchFn: fetchFn as unknown as typeof fetch, + }) + + const app = new Hono() + app.route('/api/opencode', createAuthenticatedOpenCodeProxyRoutes(client, passThroughAuth)) + + const responsePromise = app.request('/api/opencode/event', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }), + }) + + upstreamController.enqueue(encoder.encode('first-chunk')) + const response = await responsePromise + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/event-stream') + + const reader = response.body!.getReader() + const first = await reader.read() + expect(decoder.decode(first.value)).toBe('first-chunk') + + upstreamController.enqueue(encoder.encode('second-chunk')) + const second = await reader.read() + expect(decoder.decode(second.value)).toBe('second-chunk') + upstreamController.close() + await reader.cancel() + + expect(capturedInit?.body).toBeInstanceOf(ReadableStream) + expect(capturedInit?.duplex).toBe('half') + }) +}) diff --git a/backend/test/routes/opencode-proxy.test.ts b/backend/test/routes/opencode-proxy.test.ts index cb491644f..2ca982a9b 100644 --- a/backend/test/routes/opencode-proxy.test.ts +++ b/backend/test/routes/opencode-proxy.test.ts @@ -323,6 +323,18 @@ describe('opencode-proxy routes', () => { expect(body.error).toBe('Proxy request failed') }) + it('returns a null body for bodyless upstream statuses', async () => { + const upstreamFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + const res = await app.request('/api/opencode-proxy/doc', { + headers: { Authorization: 'Bearer test-internal-token' }, + }) + + expect(res.status).toBe(204) + expect(await res.text()).toBe('') + }) + it('preserves query string in upstream URL', async () => { const upstreamFetch = vi.fn().mockResolvedValue( new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }) diff --git a/backend/test/routes/opencode-server-proxy.test.ts b/backend/test/routes/opencode-server-proxy.test.ts index 515bab26f..c46d6fb38 100644 --- a/backend/test/routes/opencode-server-proxy.test.ts +++ b/backend/test/routes/opencode-server-proxy.test.ts @@ -236,6 +236,23 @@ describe('opencode server proxy routes', () => { expect(receivedSignal?.aborted).toBe(false) }) + it('does not time out a bodyless POST that takes longer than the headers timeout', async () => { + let receivedSignal: AbortSignal | null | undefined + const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { + receivedSignal = init.signal + await new Promise((resolve) => setTimeout(resolve, 40)) + return new Response('ok', { status: 200 }) + }) + const app = buildApp(fetchMock as unknown as typeof fetch, passThroughAuth, 10) + + const res = await app.request('/api/opencode/servers/srv_1/api/session/ses_1/abort', { + method: 'POST', + }) + + expect(res.status).toBe(200) + expect(receivedSignal?.aborted).toBe(false) + }) + it('keeps streaming the upstream body after headers arrive', async () => { const encoder = new TextEncoder() let receivedSignal: AbortSignal | null | undefined diff --git a/backend/test/routes/repo-git.test.ts b/backend/test/routes/repo-git.test.ts index 781e180a7..94ad49414 100644 --- a/backend/test/routes/repo-git.test.ts +++ b/backend/test/routes/repo-git.test.ts @@ -52,10 +52,6 @@ vi.mock('@opencode-manager/shared/config', () => ({ }, })) -vi.mock('eventsource', () => ({ - EventSource: vi.fn(), -})) - const getRepoByIdMock = db.getRepoById as MockedFunction describe('Repo Git Routes', () => { diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts index 7b7b36666..b2cef3f1b 100644 --- a/backend/test/routes/repos.test.ts +++ b/backend/test/routes/repos.test.ts @@ -47,6 +47,7 @@ vi.mock('../../src/services/repo', () => ({ vi.mock('../../src/services/assistant-mode', () => ({ getAssistantModeStatus: vi.fn(), ensureAssistantMode: vi.fn(), + convertAssistantOpenCodeConfigToV2: vi.fn(), getAssistantModeDirectory: vi.fn(), buildAssistantOpenCodeConfig: vi.fn(), buildAssistantRepo: vi.fn(), diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 649c19295..f4e91bf90 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -12,12 +12,14 @@ const mockResetSettings = vi.fn() const mockGetLastKnownGoodConfig = vi.fn() const { mockReadOpenCodeConfigFile, + mockReadOpenCodeConfigSnapshot, mockDeleteOpenCodeConfigFile, mockArchiveBrokenOpenCodeConfigFile, mockRestoreOpenCodeConfigSnapshot, mockApplyOpenCodeConfigUpdate, } = vi.hoisted(() => ({ mockReadOpenCodeConfigFile: vi.fn(), + mockReadOpenCodeConfigSnapshot: vi.fn(), mockDeleteOpenCodeConfigFile: vi.fn(), mockArchiveBrokenOpenCodeConfigFile: vi.fn(), mockRestoreOpenCodeConfigSnapshot: vi.fn(), @@ -183,6 +185,7 @@ vi.mock('../../src/services/opencode-config-file', async (importOriginal) => { return { ...actual, readOpenCodeConfigFile: mockReadOpenCodeConfigFile, + readOpenCodeConfigSnapshot: mockReadOpenCodeConfigSnapshot, deleteOpenCodeConfigFile: mockDeleteOpenCodeConfigFile, archiveBrokenOpenCodeConfigFile: mockArchiveBrokenOpenCodeConfigFile, restoreOpenCodeConfigSnapshot: mockRestoreOpenCodeConfigSnapshot, @@ -327,6 +330,7 @@ import { detectSandboxCapability } from '../../src/services/sandbox/capability' import { forceProcessAttestation } from '../../src/services/opencode/process-identity' import { sseAggregator } from '../../src/services/sse-aggregator' import { createRepo } from '../../src/db/queries' +import { ClientError } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from '../../src/services/opencode/client' const mockSpawnSync = spawnSync as ReturnType @@ -363,6 +367,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockGetImportedSessionDirectories.mockReset() mockRelinkReposFromSessionDirectories.mockReset() mockReadOpenCodeConfigFile.mockReset() + mockReadOpenCodeConfigSnapshot.mockReset() mockDeleteOpenCodeConfigFile.mockReset() mockArchiveBrokenOpenCodeConfigFile.mockReset() mockRestoreOpenCodeConfigSnapshot.mockReset() @@ -443,6 +448,113 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.error).toBe('No OpenCode config file found') }) + it('returns the managed config merged from Manager source documents', async () => { + mockReadOpenCodeConfigSnapshot.mockResolvedValueOnce({ + sources: [ + { name: 'opencode.json', path: '/tmp/test-workspace/config/opencode.json', rawContent: '{}', content: {}, isValid: true, updatedAt: 1 }, + { name: 'opencode.jsonc', path: '/tmp/test-workspace/config/opencode.jsonc', rawContent: '{}', content: {}, isValid: true, updatedAt: 1 }, + ], + content: {}, + revision: 'rev-managed', + updatedAt: 1, + path: '/tmp/test-workspace/config/opencode.jsonc', + rawContent: '{}', + }) + const configGet = vi.fn(async () => [ + { type: 'document', path: '/tmp/test-workspace/config/opencode.json', info: { theme: 'light', model: 'a/b' } }, + { type: 'document', path: '/tmp/test-workspace/config/opencode.jsonc', info: { theme: 'dark' } }, + { type: 'document', path: '/tmp/test-workspace/unmanaged.json', info: { theme: 'ignored' } }, + { type: 'directory', path: '/tmp/test-workspace' }, + ]) + const app = createSettingsRoutes( + testDb, + { getGitEnvironment: vi.fn().mockReturnValue({}) } as any, + createStubOpenCodeClient({ api: { config: { get: configGet } } as unknown as OpenCodeClient['api'] }), + ) + + const res = await app.fetch(new Request('http://localhost/opencode-config/managed')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + info: { theme: 'dark', model: 'a/b' }, + }) + expect(configGet).toHaveBeenCalledWith({ location: { directory: '/tmp/test-workspace' } }) + }) + + it('returns 503 from GET /opencode-config/managed when OpenCode is unavailable', async () => { + mockReadOpenCodeConfigSnapshot.mockResolvedValueOnce({ + sources: [ + { name: 'opencode.jsonc', path: '/tmp/test-workspace/config/opencode.jsonc', rawContent: '{}', content: {}, isValid: true, updatedAt: 1 }, + ], + content: {}, + revision: 'rev-managed', + updatedAt: 1, + path: '/tmp/test-workspace/config/opencode.jsonc', + rawContent: '{}', + }) + const app = createSettingsRoutes( + testDb, + { getGitEnvironment: vi.fn().mockReturnValue({}) } as any, + createStubOpenCodeClient({ + api: { + config: { + get: vi.fn(async () => { + throw new ClientError('Transport') + }), + }, + } as unknown as OpenCodeClient['api'], + }), + ) + + const res = await app.fetch(new Request('http://localhost/opencode-config/managed')) + const json = await res.json() as Record + + expect(res.status).toBe(503) + expect(json.error).toBe('OpenCode server unavailable') + }) + + it('returns MCP servers from the managed config without a shape field', async () => { + mockReadOpenCodeConfigSnapshot.mockResolvedValueOnce({ + sources: [ + { name: 'opencode.jsonc', path: '/tmp/test-workspace/config/opencode.jsonc', rawContent: '{}', content: {}, isValid: true, updatedAt: 1 }, + ], + content: {}, + revision: 'rev-managed', + updatedAt: 1, + path: '/tmp/test-workspace/config/opencode.jsonc', + rawContent: '{}', + }) + const configGet = vi.fn(async () => [ + { + type: 'document', + path: '/tmp/test-workspace/config/opencode.jsonc', + info: { mcp: { servers: { local: { type: 'local', command: ['npx', 'local'] } } } }, + }, + ]) + const mcpList = vi.fn(async () => ({ + data: [{ name: 'local', status: { status: 'connected' } }], + })) + const app = createSettingsRoutes( + testDb, + { getGitEnvironment: vi.fn().mockReturnValue({}) } as any, + createStubOpenCodeClient({ + api: { config: { get: configGet }, mcp: { list: mcpList } } as unknown as OpenCodeClient['api'], + }), + ) + + const res = await app.fetch(new Request('http://localhost/opencode-config/mcp')) + const json = await res.json() as { revision: string; servers: Array> } + + expect(res.status).toBe(200) + expect(json).toEqual({ + revision: 'rev-managed', + servers: [ + { name: 'local', type: 'local', command: ['npx', 'local'], enabled: true, status: 'connected' }, + ], + }) + expect(json.servers.every((server) => !('shape' in server))).toBe(true) + }) + it('maps a restart-pending apply result to 200 with restartRequired', async () => { const config = { path: '/tmp/test-workspace/.config/opencode.json', @@ -499,6 +611,34 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) }) + it('returns 503 from a PATCH entity save when OpenCode cannot be read', async () => { + mockApplyOpenCodeConfigUpdate.mockRejectedValueOnce(new ClientError('Transport')) + + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ patch: { agents: { reviewer: { description: 'new' } } } }), + })) + + expect(res.status).toBe(503) + expect(await res.json()).toEqual({ error: 'OpenCode server unavailable' }) + }) + + it('refuses a PATCH entity save with 409 while an OpenCode restart is pending', async () => { + vi.mocked(opencodeServerManager.isRestartPending).mockReturnValueOnce(true) + + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ patch: { agents: { reviewer: { description: 'stale' } } } }), + })) + const json = await res.json() as Record + + expect(res.status).toBe(409) + expect(json.error).toContain('Restart OpenCode') + expect(mockApplyOpenCodeConfigUpdate).not.toHaveBeenCalled() + }) + it('returns 400 when the PUT body fails schema validation', async () => { const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { method: 'PUT', diff --git a/backend/test/routes/ssh.test.ts b/backend/test/routes/ssh.test.ts index 717e84487..95c938236 100644 --- a/backend/test/routes/ssh.test.ts +++ b/backend/test/routes/ssh.test.ts @@ -32,10 +32,6 @@ vi.mock('@opencode-manager/shared/config', () => ({ }, })) -vi.mock('eventsource', () => ({ - EventSource: vi.fn(), -})) - vi.mock('../../src/utils/logger', () => ({ logger: { info: vi.fn(), diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index 0054cd755..899d75d7f 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -3,7 +3,10 @@ import path from 'path' import { access, readFile, writeFile } from 'fs/promises' import { Hono } from 'hono' import { AssistantSettingsPatchSchema } from '@opencode-manager/shared/schemas' -import { ensureAssistantMode, getAssistantModeStatus, buildSchedulesSkill, buildReposSkill, buildSettingsSkill, buildSessionsSkill, buildAssistantDefaultAgentMd, buildAssistantOpenCodeConfig, buildAssistantRepo, installAssistantWorkspace } from '../../src/services/assistant-mode' +import { parseJsonc } from '@opencode-manager/shared/utils' +import type { OpenCodeApi, OpenCodePermissionRule } from '@opencode-manager/shared/opencode' +import { validateOpenCodeConfig } from '@opencode-manager/shared/opencode/config-validation' +import { ensureAssistantMode, getAssistantModeStatus, buildSchedulesSkill, buildReposSkill, buildSettingsSkill, buildSessionsSkill, buildAssistantDefaultAgentMd, buildAssistantOpenCodeConfig, buildAssistantRepo, installAssistantWorkspace, convertAssistantOpenCodeConfigToV2 } from '../../src/services/assistant-mode' import { createTempAssistantWorkspace, createTestDb, mockRepo } from '../helpers/assistant-workspace' import { ScheduleService } from '../../src/services/schedules' import { getOrCreateInternalToken } from '../../src/services/internal-token' @@ -12,6 +15,16 @@ import { getRepoById } from '../../src/db/queries' import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' import { createInternalTestApp } from '../helpers/internal-test-app' +const ASSISTANT_PERMISSIONS: OpenCodePermissionRule[] = [ + { action: 'read', resource: '*', effect: 'allow' }, + { action: 'edit', resource: '*', effect: 'allow' }, + { action: 'glob', resource: '*', effect: 'allow' }, + { action: 'grep', resource: '*', effect: 'allow' }, + { action: 'list', resource: '*', effect: 'allow' }, + { action: 'shell', resource: '*', effect: 'allow' }, + { action: 'external_directory', resource: '*', effect: 'ask' }, +] + describe('buildSchedulesSkill', () => { it('instructs the agent to use the ocm tool request action', () => { const skill = buildSchedulesSkill() @@ -149,6 +162,25 @@ describe('buildSettingsSkill', () => { expect(skill).toContain('never copy this response into a save') }) + it('names v2 config keys and documents the unreachable server response', () => { + const skill = buildSettingsSkill() + expect(skill).toContain('`plugins`') + expect(skill).toContain('`mcp.servers`') + expect(skill).toContain('`providers`') + expect(skill).not.toContain('including `plugin`') + expect(skill).toContain('GET /opencode-config/mcp returns `503`') + }) + + it('documents v1 counterpart removal, reference preservation, and both 409 causes', () => { + const skill = buildSettingsSkill() + expect(skill).toContain('also deletes its v1 counterpart') + expect(skill).toContain('`{env:…}` and `{file:…}` references') + expect(skill).toContain('OpenCodeConfigShadowedRemovalError') + expect(skill).toContain('expectedRevision') + expect(skill).not.toContain('Saving never silently drops unsupported fields') + expect(skill).not.toContain('every path you do not name is left untouched') + }) + it('documents the assistant reload as rebuilding every loaded location', () => { const skill = buildSettingsSkill() expect(skill).toContain('rebuilding every loaded location') @@ -202,13 +234,15 @@ describe('buildAssistantDefaultAgentMd', () => { }) describe('buildAssistantOpenCodeConfig', () => { - it('includes default_agent and agent.assistant with primary mode and no embedded persona', () => { + it('writes v2 permissions and agents.assistant with primary mode and no embedded persona', () => { const config = buildAssistantOpenCodeConfig() expect(config.default_agent).toBe('assistant') - expect(config.agent?.assistant).toEqual({ mode: 'primary' }) - expect(config.agent?.assistant?.prompt).toBeUndefined() - expect(config.agent?.assistant?.description).toBeUndefined() - expect(config.agent?.assistant?.permission).toBeUndefined() + expect(config.permissions).toEqual(ASSISTANT_PERMISSIONS) + expect(config.agents?.assistant).toEqual({ mode: 'primary' }) + expect(config.agents?.assistant?.system).toBeUndefined() + expect(config.agents?.assistant?.description).toBeUndefined() + expect(config.agents?.assistant?.permissions).toBeUndefined() + expect(validateOpenCodeConfig(config).success).toBe(true) }) }) @@ -234,10 +268,11 @@ describe('ensureAssistantMode', () => { const parsedConfig = JSON.parse(opencodeJson) expect(parsedConfig.default_agent).toBe('assistant') expect(parsedConfig).not.toHaveProperty('mcp') - expect(parsedConfig.agent?.assistant).toEqual({ mode: 'primary' }) - expect(parsedConfig.agent?.assistant?.prompt).toBeUndefined() - expect(parsedConfig.agent?.assistant?.description).toBeUndefined() - expect(parsedConfig.agent?.assistant?.permission).toBeUndefined() + expect(parsedConfig.permissions).toEqual(ASSISTANT_PERMISSIONS) + expect(parsedConfig.agents?.assistant).toEqual({ mode: 'primary' }) + expect(parsedConfig.agents?.assistant?.system).toBeUndefined() + expect(parsedConfig.agents?.assistant?.description).toBeUndefined() + expect(parsedConfig.agents?.assistant?.permissions).toBeUndefined() expect(skill).toContain('"action": "request"') expect(skill).not.toContain('curl') expect(skill).not.toContain('Authorization: Bearer') @@ -275,19 +310,11 @@ describe('ensureAssistantMode', () => { expect(opencodeJson.default_agent).toBe('assistant') expect(opencodeJson.instructions).toBeUndefined() - expect(opencodeJson.permission).toEqual({ - read: 'allow', - edit: 'allow', - glob: 'allow', - grep: 'allow', - list: 'allow', - shell: 'allow', - external_directory: 'ask', - }) - expect(opencodeJson.agent?.assistant).toEqual({ mode: 'primary' }) - expect(opencodeJson.agent?.assistant?.prompt).toBeUndefined() - expect(opencodeJson.agent?.assistant?.description).toBeUndefined() - expect(opencodeJson.agent?.assistant?.permission).toBeUndefined() + expect(opencodeJson.permissions).toEqual(ASSISTANT_PERMISSIONS) + expect(opencodeJson.agents?.assistant).toEqual({ mode: 'primary' }) + expect(opencodeJson.agents?.assistant?.system).toBeUndefined() + expect(opencodeJson.agents?.assistant?.description).toBeUndefined() + expect(opencodeJson.agents?.assistant?.permissions).toBeUndefined() const agentsMdContent = await readFile(agentsMdPath, 'utf8') expect(agentsMdContent).toContain('Assistant Mode Workspace') @@ -374,21 +401,22 @@ describe('ensureAssistantMode', () => { model: 'provider/model', instructions: ['AGENTS.md'], default_agent: 'build', - agent: { - custom: { mode: 'primary', prompt: 'Custom agent' }, + agents: { + custom: { mode: 'primary', system: 'Custom agent' }, }, - skills: { paths: ['.opencode/skills'] }, + skills: ['.opencode/skills'], }, null, 2)) const result = await ensureAssistantMode(mockRepo) const repaired = JSON.parse(await readFile(opencodeJsonPath, 'utf8')) expect(repaired.default_agent).toBe('assistant') - expect(repaired.agent.assistant).toEqual({ mode: 'primary', disable: false }) - expect(repaired.agent.assistant.prompt).toBeUndefined() - expect(repaired.agent.custom.prompt).toBe('Custom agent') + expect(repaired.permissions).toEqual(ASSISTANT_PERMISSIONS) + expect(repaired.agents.assistant).toEqual({ mode: 'primary', disabled: false }) + expect(repaired.agents.assistant.system).toBeUndefined() + expect(repaired.agents.custom.system).toBe('Custom agent') expect(repaired.model).toBe('provider/model') - expect(repaired.skills.paths).toEqual(['.opencode/skills']) + expect(repaired.skills).toEqual(['.opencode/skills']) expect(result.files.opencodeJson?.created).toBe(true) }) @@ -397,12 +425,12 @@ describe('ensureAssistantMode', () => { const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') await writeFile(opencodeJsonPath, JSON.stringify({ default_agent: 'assistant', - agent: { + agents: { assistant: { mode: 'subagent', - prompt: 'Custom assistant prompt', + system: 'Custom assistant prompt', description: 'Custom assistant', - permission: { bash: 'ask' }, + permissions: [{ action: 'shell', resource: '*', effect: 'ask' }], }, }, }, null, 2)) @@ -410,11 +438,45 @@ describe('ensureAssistantMode', () => { const result = await ensureAssistantMode(mockRepo) const repaired = JSON.parse(await readFile(opencodeJsonPath, 'utf8')) - expect(repaired.agent.assistant.prompt).toBe('Custom assistant prompt') - expect(repaired.agent.assistant.description).toBe('Custom assistant') - expect(repaired.agent.assistant.permission.bash).toBe('ask') - expect(repaired.agent.assistant.mode).toBe('primary') - expect(repaired.agent.assistant.disable).toBe(false) + expect(repaired.agents.assistant.system).toBe('Custom assistant prompt') + expect(repaired.agents.assistant.description).toBe('Custom assistant') + expect(repaired.agents.assistant.permissions).toEqual([{ action: 'shell', resource: '*', effect: 'ask' }]) + expect(repaired.agents.assistant.mode).toBe('primary') + expect(repaired.agents.assistant.disabled).toBe(false) + expect(result.files.opencodeJson?.created).toBe(true) + }) + + it('leaves a v1 assistant opencode config untouched so the conversion can upgrade it', async () => { + await ensureAssistantMode(mockRepo) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + const v1Config = `{ + // keep this comment + "default_agent": "assistant", + "permission": { "read": "allow", "shell": "ask" }, + "agent": { "assistant": { "mode": "primary", "prompt": "Custom v1 prompt" } } +} +` + await writeFile(opencodeJsonPath, v1Config) + + const result = await ensureAssistantMode(mockRepo) + + expect(await readFile(opencodeJsonPath, 'utf8')).toBe(v1Config) + expect(result.files.opencodeJson?.created).toBe(false) + }) + + it('writes the generated v2 config over a v1 config when overwrite is requested', async () => { + await ensureAssistantMode(mockRepo) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + const v1Config = `{ + "permission": { "read": "allow", "shell": "ask" }, + "agent": { "assistant": { "mode": "primary", "prompt": "Custom v1 prompt" } } +} +` + await writeFile(opencodeJsonPath, v1Config) + + const result = await ensureAssistantMode(mockRepo, { overwriteOpenCodeConfig: true }) + + expect(JSON.parse(await readFile(opencodeJsonPath, 'utf8'))).toEqual(buildAssistantOpenCodeConfig()) expect(result.files.opencodeJson?.created).toBe(true) }) @@ -508,29 +570,13 @@ Ask before destructive operations or changes outside this assistant workspace. await writeFile(opencodeJsonPath, JSON.stringify({ default_agent: 'assistant', instructions: ['AGENTS.md'], - permission: { - read: 'allow', - edit: 'allow', - glob: 'allow', - grep: 'allow', - list: 'allow', - bash: 'allow', - external_directory: 'ask', - }, - agent: { + permissions: [{ action: 'read', resource: '*', effect: 'allow' }], + agents: { assistant: { description: 'Default OpenCode Manager assistant workspace agent', mode: 'primary', - prompt: legacyAssistantPrompt, - permission: { - read: 'allow', - edit: 'allow', - glob: 'allow', - grep: 'allow', - list: 'allow', - bash: 'allow', - external_directory: 'ask', - }, + system: legacyAssistantPrompt, + permissions: [{ action: 'shell', resource: '*', effect: 'allow' }], }, }, }, null, 2)) @@ -553,10 +599,11 @@ Ask before destructive operations or changes outside this assistant workspace. expect(updatedAssistantAgent).toContain('notifications') expect(updatedAssistantAgent).toContain('manager-settings') - expect(updatedOpenCodeJson.agent.assistant.prompt).toBeUndefined() - expect(updatedOpenCodeJson.agent.assistant.description).toBeUndefined() - expect(updatedOpenCodeJson.agent.assistant.permission).toBeUndefined() - expect(updatedOpenCodeJson.agent.assistant.mode).toBe('primary') + expect(updatedOpenCodeJson.agents.assistant.system).toBeUndefined() + expect(updatedOpenCodeJson.agents.assistant.description).toBeUndefined() + expect(updatedOpenCodeJson.agents.assistant.permissions).toBeUndefined() + expect(updatedOpenCodeJson.agents.assistant.mode).toBe('primary') + expect(updatedOpenCodeJson.permissions).toEqual([{ action: 'read', resource: '*', effect: 'allow' }]) expect(result.files.agentsMd?.created).toBe(true) expect(result.files.opencodeJson?.created).toBe(true) @@ -673,6 +720,239 @@ Ask before destructive operations or changes outside this assistant workspace. }) }) +describe('convertAssistantOpenCodeConfigToV2', () => { + let ws: Awaited> + + beforeEach(async () => { + ws = await createTempAssistantWorkspace() + }) + afterEach(async () => { await ws.cleanup() }) + + function createConfigApi( + getEntries: (assistantDir: string) => unknown[], + ): OpenCodeApi { + return { + config: { + get: async () => getEntries(ws.assistantDir), + }, + } as unknown as OpenCodeApi + } + + const v1Config = `{ + // keep this comment + "default_agent": "assistant", + "permission": { "read": "allow", "shell": "ask" }, + "agent": { "assistant": { "mode": "primary", "prompt": "Custom v1 prompt" } } +} +` + + const convertedPermissions = [ + { action: 'read', resource: '*', effect: 'allow' }, + { action: 'shell', resource: '*', effect: 'ask' }, + ] + + it('writes the converted v2 values and removes the v1 keys while keeping comments', async () => { + await ensureAssistantMode(mockRepo) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + await writeFile(opencodeJsonPath, v1Config) + + const api = createConfigApi((assistantDir) => [{ + type: 'document', + path: path.join(assistantDir, 'opencode.json'), + info: { + default_agent: 'assistant', + permissions: convertedPermissions, + agents: { assistant: { mode: 'primary', system: 'Converted prompt' } }, + }, + }]) + + const converted = await convertAssistantOpenCodeConfigToV2(api) + + const raw = await readFile(opencodeJsonPath, 'utf8') + const config = parseJsonc>(raw) + expect(converted).toBe(true) + expect(config.agents).toEqual({ assistant: { mode: 'primary', system: 'Converted prompt' } }) + expect(config.permissions).toEqual(convertedPermissions) + expect(config.agent).toBeUndefined() + expect(config.permission).toBeUndefined() + expect(raw).toContain('// keep this comment') + }) + + it('keeps comments attached to retained keys in a commented v1 config', async () => { + await ensureAssistantMode(mockRepo) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + await writeFile(opencodeJsonPath, `{ + // about agent + "agent": { + // assistant comment + "assistant": { "mode": "primary", "prompt": "Custom v1 prompt" }, + // kept sibling comment + "reviewer": { "mode": "subagent", "prompt": "Reviewer" } + }, + // about permission + "permission": { "read": "allow", "shell": "ask" }, // trailing note + "theme": "opencode" +} +`) + + const api = createConfigApi((assistantDir) => [{ + type: 'document', + path: path.join(assistantDir, 'opencode.json'), + info: { + default_agent: 'assistant', + permissions: convertedPermissions, + agents: { assistant: { mode: 'primary', system: 'Converted prompt' } }, + }, + }]) + + expect(await convertAssistantOpenCodeConfigToV2(api)).toBe(true) + + const raw = await readFile(opencodeJsonPath, 'utf8') + const config = parseJsonc>(raw) + expect(config.agents).toEqual({ assistant: { mode: 'primary', system: 'Converted prompt' } }) + expect(config.permissions).toEqual(convertedPermissions) + expect(config.agent).toEqual({ reviewer: { mode: 'subagent', prompt: 'Reviewer' } }) + expect(config.permission).toBeUndefined() + expect(config.theme).toBe('opencode') + expect(raw).toContain('// about agent') + expect(raw).toContain('// kept sibling comment') + expect(raw).toContain('// about permission') + expect(raw).toContain('// trailing note') + }) + + it('keeps other v1 agent entries when only agent.assistant is converted', async () => { + await ensureAssistantMode(mockRepo) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + await writeFile(opencodeJsonPath, JSON.stringify({ + default_agent: 'assistant', + agent: { + assistant: { mode: 'primary', prompt: 'Custom v1 prompt' }, + reviewer: { mode: 'subagent', prompt: 'Reviewer' }, + }, + }, null, 2)) + + const api = createConfigApi((assistantDir) => [{ + type: 'document', + path: path.join(assistantDir, 'opencode.json'), + info: { + default_agent: 'assistant', + permissions: convertedPermissions, + agents: { assistant: { mode: 'primary', system: 'Converted prompt' } }, + }, + }]) + + expect(await convertAssistantOpenCodeConfigToV2(api)).toBe(true) + + const config = JSON.parse(await readFile(opencodeJsonPath, 'utf8')) + expect(config.agents.assistant.system).toBe('Converted prompt') + expect(config.agent.assistant).toBeUndefined() + expect(config.agent.reviewer.prompt).toBe('Reviewer') + }) + + it('drops a generated assistant persona from the converted agent', async () => { + await ensureAssistantMode(mockRepo) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + await writeFile(opencodeJsonPath, JSON.stringify({ + default_agent: 'assistant', + agent: { assistant: { mode: 'all' } }, + }, null, 2)) + + const generatedPrompt = buildAssistantDefaultAgentMd().split('---\n\n')[1]?.trimEnd() + if (generatedPrompt === undefined) throw new Error('Generated assistant prompt is missing') + + const api = createConfigApi((assistantDir) => [{ + type: 'document', + path: path.join(assistantDir, 'opencode.json'), + info: { + default_agent: 'assistant', + permissions: convertedPermissions, + agents: { assistant: { mode: 'all', system: generatedPrompt, description: 'Generated persona' } }, + }, + }]) + + expect(await convertAssistantOpenCodeConfigToV2(api)).toBe(true) + + const config = JSON.parse(await readFile(opencodeJsonPath, 'utf8')) + expect(config.agents.assistant).toEqual({ mode: 'all' }) + }) + + it('keeps the v1 permission block when the converted document has no permissions', async () => { + await ensureAssistantMode(mockRepo) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + await writeFile(opencodeJsonPath, v1Config) + + const api = createConfigApi((assistantDir) => [{ + type: 'document', + path: path.join(assistantDir, 'opencode.json'), + info: { + default_agent: 'assistant', + agents: { assistant: { mode: 'primary', system: 'Converted prompt' } }, + }, + }]) + + expect(await convertAssistantOpenCodeConfigToV2(api)).toBe(true) + + const config = parseJsonc>(await readFile(opencodeJsonPath, 'utf8')) + expect(config.agents).toEqual({ assistant: { mode: 'primary', system: 'Converted prompt' } }) + expect(config.permission).toEqual({ read: 'allow', shell: 'ask' }) + expect(config.agent).toBeUndefined() + }) + + it('restores a config reference from the v1 agent definition', async () => { + await ensureAssistantMode(mockRepo) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + await writeFile(opencodeJsonPath, JSON.stringify({ + default_agent: 'assistant', + agent: { assistant: { mode: 'primary', prompt: '{file:./p.md}' } }, + }, null, 2)) + + const api = createConfigApi((assistantDir) => [{ + type: 'document', + path: path.join(assistantDir, 'opencode.json'), + info: { + default_agent: 'assistant', + agents: { assistant: { mode: 'primary', system: 'Resolved prompt text' } }, + }, + }]) + + expect(await convertAssistantOpenCodeConfigToV2(api)).toBe(true) + + const config = JSON.parse(await readFile(opencodeJsonPath, 'utf8')) + expect(config.agents.assistant).toEqual({ mode: 'primary', system: '{file:./p.md}' }) + expect(config.agent).toBeUndefined() + }) + + it('leaves the file unchanged when the OpenCode client fails', async () => { + await ensureAssistantMode(mockRepo) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + await writeFile(opencodeJsonPath, v1Config) + + const api = { + config: { + get: async () => { throw new Error('unreachable') }, + }, + } as unknown as OpenCodeApi + + const converted = await convertAssistantOpenCodeConfigToV2(api) + + expect(converted).toBe(false) + expect(await readFile(opencodeJsonPath, 'utf8')).toBe(v1Config) + }) + + it('does nothing for a v2 config', async () => { + await ensureAssistantMode(mockRepo) + let calls = 0 + const api = { + config: { + get: async () => { calls += 1; return [] }, + }, + } as unknown as OpenCodeApi + + expect(await convertAssistantOpenCodeConfigToV2(api)).toBe(false) + expect(calls).toBe(0) + }) +}) + describe('assistant-mode end-to-end', () => { let ws: Awaited> let db: ReturnType diff --git a/backend/test/services/git-auth.test.ts b/backend/test/services/git-auth.test.ts index f182c931b..012821b76 100644 --- a/backend/test/services/git-auth.test.ts +++ b/backend/test/services/git-auth.test.ts @@ -41,10 +41,6 @@ vi.mock('@opencode-manager/shared/config', () => ({ }, })) -vi.mock('eventsource', () => ({ - EventSource: vi.fn(), -})) - vi.mock('../../src/utils/ssh-key-manager', () => ({ writeTemporarySSHKey: vi.fn().mockResolvedValue('/tmp/test-workspace/.ssh-keys/test-key'), cleanupSSHKey: vi.fn().mockResolvedValue(undefined), diff --git a/backend/test/services/opencode-config-apply.test.ts b/backend/test/services/opencode-config-apply.test.ts index 624ff7f83..830d572e8 100644 --- a/backend/test/services/opencode-config-apply.test.ts +++ b/backend/test/services/opencode-config-apply.test.ts @@ -3,7 +3,6 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import path from 'path' import { Database } from 'bun:sqlite' -import { ZodError } from 'zod' const paths = vi.hoisted(() => ({ config: '', configDir: '', configHome: '', healthWatch: '' })) @@ -12,6 +11,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ getOpenCodeConfigFilePath: () => paths.config, getOpenCodeConfigHome: () => paths.configHome, getOpenCodeHealthWatchPath: () => paths.healthWatch, + getWorkspacePath: () => paths.configDir, OPENCODE_CONFIG_SOURCE_NAMES: ['opencode.json', 'opencode.jsonc'], })) @@ -48,6 +48,7 @@ import { OPENCODE_CONFIG_SEED, OpenCodeConfigConflictError, OpenCodeConfigSourceInvalidError, + OpenCodeConfigValidationError, readOpenCodeConfigFile, writeOpenCodeConfigFile, } from '../../src/services/opencode-config-file' @@ -67,8 +68,10 @@ function parseSnapshot(snapshot: string): { version: number; sources: Array<{ na const locationReloadMock = vi.hoisted(() => vi.fn<() => Promise>()) const mcpAddMock = vi.hoisted(() => vi.fn<() => Promise>()) const mcpRemoveMock = vi.hoisted(() => vi.fn<() => Promise>()) +const configGetMock = vi.hoisted(() => vi.fn<() => Promise>()) const openCodeClient = { api: { + config: { get: configGetMock }, location: { reload: locationReloadMock }, mcp: { add: mcpAddMock, remove: mcpRemoveMock }, }, @@ -88,6 +91,7 @@ describe('opencode-config-apply', () => { locationReloadMock.mockReset().mockResolvedValue(undefined) mcpAddMock.mockReset().mockResolvedValue(undefined) mcpRemoveMock.mockReset().mockResolvedValue(undefined) + configGetMock.mockReset().mockRejectedValue(new Error('OpenCode unavailable')) workDir = await mkdtemp(path.join(tmpdir(), 'opencode-config-apply-')) paths.config = path.join(workDir, 'opencode.json') paths.configDir = workDir @@ -126,6 +130,62 @@ describe('opencode-config-apply', () => { expect(result.config.content).toEqual(change) }) + it('keeps an unchanged {env:} reference when a merge patch resends the resolved value', async () => { + await writeFile( + sourcePath('opencode.json'), + '{\n // keep me\n "providers": { "acme": { "settings": { "apiKey": "{env:ACME_KEY}" } } }\n}\n', + 'utf8', + ) + configGetMock.mockResolvedValue([ + { + type: 'document', + path: sourcePath('opencode.json'), + info: { providers: { acme: { settings: { apiKey: 'secret' } } } }, + }, + ]) + + await applyOpenCodeConfigUpdate({ + content: { providers: { acme: { name: 'Acme', settings: { apiKey: 'secret' } } } }, + mode: 'merge', + settingsService, + openCodeClient, + }) + + const written = await readFile(sourcePath('opencode.json'), 'utf8') + expect(written).toContain('"apiKey": "{env:ACME_KEY}"') + expect(written).toContain('"name": "Acme"') + expect(written).toContain('// keep me') + expect(written).not.toContain('secret') + }) + + it('refuses an entity merge patch without writing when the resolved config cannot be read', async () => { + const original = '{\n "providers": { "acme": { "settings": { "apiKey": "{env:ACME_KEY}" } } }\n}\n' + await writeFile(sourcePath('opencode.json'), original, 'utf8') + + await expect(applyOpenCodeConfigUpdate({ + content: { providers: { acme: { name: 'Acme', settings: { apiKey: 'secret' } } } }, + mode: 'merge', + settingsService, + openCodeClient, + })).rejects.toThrow('OpenCode unavailable') + + expect(await readFile(sourcePath('opencode.json'), 'utf8')).toBe(original) + }) + + it('saves a merge patch without named entities while OpenCode is unavailable', async () => { + await writeFile(sourcePath('opencode.json'), '{"theme":"dark"}', 'utf8') + + await applyOpenCodeConfigUpdate({ + content: { theme: 'light' }, + mode: 'merge', + settingsService, + openCodeClient, + }) + + expect(configGetMock).not.toHaveBeenCalled() + expect(JSON.parse(await readFile(sourcePath('opencode.json'), 'utf8'))).toEqual({ theme: 'light' }) + }) + it('marks a restart pending when the reload fails', async () => { await writeFile(sourcePath('opencode.json'), '{"theme":"dark"}', 'utf8') locationReloadMock.mockRejectedValue(new Error('OpenCode server unavailable')) @@ -231,7 +291,7 @@ describe('opencode-config-apply', () => { source: 'opencode.json', settingsService, openCodeClient, - })).rejects.toBeInstanceOf(ZodError) + })).rejects.toBeInstanceOf(OpenCodeConfigValidationError) await expect(readFile(sourcePath('opencode.json'), 'utf8')).resolves.toBe(previous) expect(markRestartPendingMock).not.toHaveBeenCalled() diff --git a/backend/test/services/opencode-config-effective.test.ts b/backend/test/services/opencode-config-effective.test.ts new file mode 100644 index 000000000..c2004175c --- /dev/null +++ b/backend/test/services/opencode-config-effective.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { getWorkspacePath } from '@opencode-manager/shared/config/env' +import { ClientError, openCodeLocation, type OpenCodeApi } from '@opencode-manager/shared/opencode' +import { readManagedOpenCodeConfig, readOpenCodeConfigDocuments } from '../../src/services/opencode-config-effective' + +function stubApi(entries: unknown[]) { + const get = vi.fn(async () => entries) + return { api: { config: { get } } as unknown as OpenCodeApi, get } +} + +describe('readOpenCodeConfigDocuments', () => { + it('keys document entries by resolved path for the requested directory', async () => { + const { api, get } = stubApi([ + { type: 'document', path: '/directory/./opencode.json', info: { theme: 'dark' } }, + { type: 'document', info: { theme: 'pathless' } }, + { type: 'directory', path: '/directory' }, + ]) + + const documents = await readOpenCodeConfigDocuments(api, '/directory') + + expect([...documents.keys()]).toEqual(['/directory/opencode.json']) + expect(documents.get('/directory/opencode.json')).toEqual({ theme: 'dark' }) + expect(get).toHaveBeenCalledWith(openCodeLocation('/directory')) + }) +}) + +describe('readManagedOpenCodeConfig', () => { + it('merges only the documents whose path is a Manager source, in source order', async () => { + const { api, get } = stubApi([ + { + type: 'document', + path: '/workspace/config/opencode.json', + info: { theme: 'light', mcp: { servers: { local: { type: 'local', command: ['npx', 'local'] } } } }, + }, + { + type: 'document', + path: '/workspace/config/opencode.jsonc', + info: { theme: 'dark', mcp: { servers: { remote: { type: 'remote', url: 'https://remote.example.com' } } } }, + }, + { type: 'document', path: '/workspace/unmanaged/opencode.json', info: { theme: 'ignored' } }, + { type: 'document', info: { theme: 'pathless' } }, + { type: 'directory', path: '/workspace/config' }, + ]) + + const info = await readManagedOpenCodeConfig(api, [ + { path: '/workspace/config/opencode.json' }, + { path: '/workspace/config/opencode.jsonc' }, + ]) + + expect(info).toEqual({ + theme: 'dark', + mcp: { + servers: { + local: { type: 'local', command: ['npx', 'local'] }, + remote: { type: 'remote', url: 'https://remote.example.com' }, + }, + }, + }) + expect(get).toHaveBeenCalledWith(openCodeLocation(getWorkspacePath())) + }) + + it('resolves both the source and reported path before matching', async () => { + const { api } = stubApi([ + { + type: 'document', + path: '/workspace/config/./opencode.json', + info: { theme: 'dark' }, + }, + ]) + + await expect( + readManagedOpenCodeConfig(api, [{ path: '/workspace/config/opencode.json' }]), + ).resolves.toEqual({ theme: 'dark' }) + }) + + it('returns the converted v1 entries the server reports for a Manager file', async () => { + const { api } = stubApi([ + { + type: 'document', + path: '/workspace/config/opencode.json', + info: { agents: { reviewer: { mode: 'subagent' } } }, + }, + ]) + + await expect( + readManagedOpenCodeConfig(api, [{ path: '/workspace/config/opencode.json' }]), + ).resolves.toEqual({ agents: { reviewer: { mode: 'subagent' } } }) + }) + + it('returns an empty config when no reported document matches a Manager source', async () => { + const { api } = stubApi([ + { type: 'document', path: '/workspace/other/opencode.json', info: { theme: 'dark' } }, + ]) + + await expect( + readManagedOpenCodeConfig(api, [{ path: '/workspace/config/opencode.json' }]), + ).resolves.toEqual({}) + }) + + it('propagates client errors', async () => { + const get = vi.fn(async () => { + throw new ClientError('Transport') + }) + const api = { config: { get } } as unknown as OpenCodeApi + + await expect( + readManagedOpenCodeConfig(api, [{ path: '/workspace/config/opencode.json' }]), + ).rejects.toBeInstanceOf(ClientError) + }) +}) diff --git a/backend/test/services/opencode-config-file.test.ts b/backend/test/services/opencode-config-file.test.ts index 49a417f37..0503ed3e6 100644 --- a/backend/test/services/opencode-config-file.test.ts +++ b/backend/test/services/opencode-config-file.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { access, chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, utimes, writeFile } from 'fs/promises' import { tmpdir } from 'os' import path from 'path' -import { ZodError } from 'zod' const paths = vi.hoisted(() => ({ config: '', configDir: '', configHome: '', healthWatch: '' })) @@ -79,6 +78,7 @@ import { OpenCodeConfigRedactedValueError, OpenCodeConfigShadowedRemovalError, OpenCodeConfigSnapshotError, + OpenCodeConfigValidationError, archiveBrokenOpenCodeConfigFile, deleteOpenCodeConfigFile, foldLegacyConfigJsonSource, @@ -88,7 +88,6 @@ import { restoreLegacyOpenCodeConfigBackup, restoreOpenCodeConfigSnapshot, serializeOpenCodeConfigSnapshot, - toOpenCodeConfigValidationIssues, updateOpenCodeConfigFile, writeHealthWatchArtifact, writeOpenCodeConfigFile, @@ -166,10 +165,10 @@ describe('opencode-config-file', () => { } }) - it('rejects invalid content with a ZodError and leaves the previous file untouched', async () => { + it('rejects invalid content with a validation error and leaves the previous file untouched', async () => { await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) - await expect(writeOpenCodeConfigFile('{"model": 5}')).rejects.toBeInstanceOf(ZodError) + await expect(writeOpenCodeConfigFile('{"model": 5}')).rejects.toBeInstanceOf(OpenCodeConfigValidationError) await expect(readFile(sourcePath('opencode.jsonc'), 'utf8')).resolves.toBe(OPENCODE_CONFIG_SEED) }) @@ -236,16 +235,13 @@ describe('opencode-config-file', () => { expect(file?.validationIssues?.[0]?.path).toBe('root') }) - it('maps Zod issues to config validation issues with a root fallback', () => { - const issues = new ZodError([ - { code: 'custom', path: ['model'], message: 'Invalid model' }, - { code: 'custom', path: [], message: 'Invalid root' }, - ]).issues + it('maps validation issues to dotted paths with a root fallback', async () => { + await writeFile(sourcePath('opencode.json'), JSON.stringify({ agents: { a: { mode: 'boss' } } }), 'utf8') - expect(toOpenCodeConfigValidationIssues(issues)).toEqual([ - { path: 'model', message: 'Invalid model' }, - { path: 'root', message: 'Invalid root' }, - ]) + const file = await readOpenCodeConfigFile() + + expect(file?.isValid).toBe(false) + expect(file?.validationIssues?.[0]?.path).toBe('agents.a.mode') }) it('modifies only changed paths in the preferred source and preserves comments and untouched fields', async () => { @@ -338,14 +334,14 @@ describe('opencode-config-file', () => { const raw = '{"theme":"dark"}' await writeFile(sourcePath('opencode.json'), raw, 'utf8') - await expect(updateOpenCodeConfigFile({ model: 5 }, { mode: 'merge' })).rejects.toBeInstanceOf(ZodError) + await expect(updateOpenCodeConfigFile({ model: 5 }, { mode: 'merge' })).rejects.toBeInstanceOf(OpenCodeConfigValidationError) await expect(readFile(sourcePath('opencode.json'), 'utf8')).resolves.toBe(raw) }) it('rejects a merge patch that removes a value only a lower-priority source defines', async () => { const lower = '{"theme":"light"}' - const target = '{"model":"b"}' + const target = '{"model":"test/b"}' await writeFile(sourcePath('opencode.json'), lower, 'utf8') await writeFile(sourcePath('opencode.jsonc'), target, 'utf8') @@ -360,7 +356,7 @@ describe('opencode-config-file', () => { it('merges into an existing nested object while deleting sibling keys with null', async () => { const raw = JSON.stringify({ - mcp: { servers: { linear: { url: 'https://linear.example.com', headers: { Authorization: 'Bearer old', 'X-Keep': '1' } } } }, + mcp: { servers: { linear: { type: 'remote', url: 'https://linear.example.com', headers: { Authorization: 'Bearer old', 'X-Keep': '1' } } } }, }) await writeFile(sourcePath('opencode.json'), raw, 'utf8') @@ -370,7 +366,7 @@ describe('opencode-config-file', () => { ) expect(updated.content).toEqual({ - mcp: { servers: { linear: { url: 'https://linear.example.com', headers: { 'X-Keep': '1' } } } }, + mcp: { servers: { linear: { type: 'remote', url: 'https://linear.example.com', headers: { 'X-Keep': '1' } } } }, }) const onDisk = JSON.parse(await readFile(sourcePath('opencode.json'), 'utf8')) as Record expect(onDisk).toEqual(updated.content) @@ -380,13 +376,13 @@ describe('opencode-config-file', () => { await writeFile(sourcePath('opencode.json'), '{"theme":"dark"}', 'utf8') const updated = await updateOpenCodeConfigFile( - { mcp: { servers: { linear: { url: 'https://linear.example.com', headers: { Authorization: null, 'X-Trace': 'on' } } } } }, + { mcp: { servers: { linear: { type: 'remote', url: 'https://linear.example.com', headers: { Authorization: null, 'X-Trace': 'on' } } } } }, { mode: 'merge' }, ) expect(updated.content).toEqual({ theme: 'dark', - mcp: { servers: { linear: { url: 'https://linear.example.com', headers: { 'X-Trace': 'on' } } } }, + mcp: { servers: { linear: { type: 'remote', url: 'https://linear.example.com', headers: { 'X-Trace': 'on' } } } }, }) const onDisk = await readFile(sourcePath('opencode.json'), 'utf8') expect(onDisk).not.toContain('null') @@ -436,25 +432,25 @@ describe('opencode-config-file', () => { }) it('removes an override from the target source only and reveals inherited values', async () => { - const lower = JSON.stringify({ theme: 'light', model: 'a' }) + const lower = JSON.stringify({ theme: 'light', model: 'test/a' }) await writeFile(sourcePath('opencode.json'), lower, 'utf8') await writeFile(sourcePath('opencode.jsonc'), '{\n "theme": "dark"\n}\n', 'utf8') - await updateOpenCodeConfigFile({ model: 'a' }) + await updateOpenCodeConfigFile({ model: 'test/a' }) await expect(readFile(sourcePath('opencode.json'), 'utf8')).resolves.toBe(lower) const revealed = await readOpenCodeConfigFile() - expect(revealed?.content).toEqual({ theme: 'light', model: 'a' }) + expect(revealed?.content).toEqual({ theme: 'light', model: 'test/a' }) }) it('rejects a removal of a value that only a lower-priority source defines and writes nothing', async () => { - const lower = '{\n "theme": "light",\n "model": "a"\n}\n' - const target = '{\n "model": "b"\n}\n' + const lower = '{\n "theme": "light",\n "model": "test/a"\n}\n' + const target = '{\n "model": "test/b"\n}\n' await writeFile(sourcePath('opencode.json'), lower, 'utf8') await writeFile(sourcePath('opencode.jsonc'), target, 'utf8') const targetStats = await stat(sourcePath('opencode.jsonc')) - const error = await updateOpenCodeConfigFile({ model: 'b' }).catch((caught: unknown) => caught) + const error = await updateOpenCodeConfigFile({ model: 'test/b' }).catch((caught: unknown) => caught) expect(error).toBeInstanceOf(OpenCodeConfigShadowedRemovalError) expect((error as OpenCodeConfigShadowedRemovalError).paths).toEqual(['theme']) @@ -465,12 +461,12 @@ describe('opencode-config-file', () => { }) it('rejects a mixed shadowed removal and legitimate change without writing anything', async () => { - const lower = '{\n "theme": "light",\n "model": "a"\n}\n' - const target = '{\n "model": "b"\n}\n' + const lower = '{\n "theme": "light",\n "model": "test/a"\n}\n' + const target = '{\n "model": "test/b"\n}\n' await writeFile(sourcePath('opencode.json'), lower, 'utf8') await writeFile(sourcePath('opencode.jsonc'), target, 'utf8') - const error = await updateOpenCodeConfigFile({ model: 'b', plugin: ['x'] }).catch((caught: unknown) => caught) + const error = await updateOpenCodeConfigFile({ model: 'test/b', plugin: ['x'] }).catch((caught: unknown) => caught) expect(error).toBeInstanceOf(OpenCodeConfigShadowedRemovalError) expect((error as OpenCodeConfigShadowedRemovalError).paths).toEqual(['theme']) @@ -794,6 +790,231 @@ describe('opencode-config-file', () => { await expect(deleteOpenCodeConfigFile()).resolves.toBe(false) }) + describe('v1 entity replacement', () => { + it.each([ + { + label: 'agents', + v1Content: { agent: { reviewer: { description: 'Legacy reviewer' } } }, + patch: { agents: { reviewer: { description: 'New reviewer' } } }, + expected: { agents: { reviewer: { description: 'New reviewer' } } }, + }, + { + label: 'commands', + v1Content: { command: { build: { template: 'legacy template' } } }, + patch: { commands: { build: { template: 'new template' } } }, + expected: { commands: { build: { template: 'new template' } } }, + }, + { + label: 'providers', + v1Content: { provider: { example: { name: 'Legacy provider' } } }, + patch: { providers: { example: { name: 'Example provider' } } }, + expected: { providers: { example: { name: 'Example provider' } } }, + }, + { + label: 'mcp servers', + v1Content: { mcp: { linear: { type: 'remote', url: 'https://legacy.example.com' } } }, + patch: { mcp: { servers: { linear: { type: 'remote', url: 'https://current.example.com' } } } }, + expected: { mcp: { servers: { linear: { type: 'remote', url: 'https://current.example.com' } } } }, + }, + { + label: 'permissions', + v1Content: { permission: { edit: 'deny' } }, + patch: { permissions: [{ action: 'edit', resource: '*', effect: 'deny' }] }, + expected: { permissions: [{ action: 'edit', resource: '*', effect: 'deny' }] }, + }, + ])('replaces the v1 $label entry when saving its v2 entity', async ({ v1Content, patch, expected }) => { + const raw = `{\n // keep this comment\n ${JSON.stringify(v1Content).slice(1, -1)}\n}\n` + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const updated = await updateOpenCodeConfigFile(patch, { mode: 'merge' }) + + expect(updated.content).toEqual(expected) + const onDisk = await readFile(sourcePath('opencode.json'), 'utf8') + expect(onDisk).toContain('// keep this comment') + }) + + it('keeps the v2 mcp timeout when saving an MCP server named like a v2 mcp key', async () => { + await writeFile( + sourcePath('opencode.json'), + JSON.stringify({ mcp: { timeout: { connect: 5000 }, servers: {} } }), + 'utf8', + ) + + const updated = await updateOpenCodeConfigFile( + { mcp: { servers: { timeout: { type: 'remote', url: 'https://timeout.example.com' } } } }, + { mode: 'merge' }, + ) + + expect(updated.content).toEqual({ + mcp: { + timeout: { connect: 5000 }, + servers: { timeout: { type: 'remote', url: 'https://timeout.example.com' } }, + }, + }) + }) + + it('keeps the v2 mcp servers when saving an MCP server named like the v2 servers map', async () => { + await writeFile( + sourcePath('opencode.json'), + JSON.stringify({ mcp: { servers: { linear: { type: 'remote', url: 'https://linear.example.com' } } } }), + 'utf8', + ) + + const updated = await updateOpenCodeConfigFile( + { mcp: { servers: { servers: { type: 'remote', url: 'https://s.example.com' } } } }, + { mode: 'merge' }, + ) + + expect(updated.content).toEqual({ + mcp: { + servers: { + linear: { type: 'remote', url: 'https://linear.example.com' }, + servers: { type: 'remote', url: 'https://s.example.com' }, + }, + }, + }) + }) + + it('keeps the v2 mcp object when the whole servers map is removed', async () => { + await writeFile( + sourcePath('opencode.json'), + JSON.stringify({ mcp: { timeout: { connect: 5000 }, servers: { linear: { type: 'remote', url: 'https://linear.example.com' } } } }), + 'utf8', + ) + + const updated = await updateOpenCodeConfigFile({ mcp: { servers: null } }, { mode: 'merge' }) + + expect(updated.content).toEqual({ mcp: { timeout: { connect: 5000 } } }) + }) + + it('keeps a comment that leads the sibling after the removed v1 container', async () => { + const raw = '{\n // lead\n "agent": {"reviewer":{"description":"Legacy reviewer"}},\n // theme comment\n "theme": "dark"\n}\n' + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const updated = await updateOpenCodeConfigFile( + { agents: { reviewer: { description: 'New reviewer' } } }, + { mode: 'merge' }, + ) + + expect(updated.content).toEqual({ + theme: 'dark', + agents: { reviewer: { description: 'New reviewer' } }, + }) + const onDisk = await readFile(sourcePath('opencode.json'), 'utf8') + expect(onDisk).toContain('// lead') + expect(onDisk).toContain('// theme comment') + expect(onDisk).not.toContain('"agent"') + }) + + it('keeps the following sibling comment when the v1 container has no leading comment', async () => { + const raw = '{\n "agent": {"reviewer":{"description":"Legacy reviewer"}},\n // theme comment\n "theme": "dark"\n}\n' + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + await updateOpenCodeConfigFile({ agents: { reviewer: { description: 'New reviewer' } } }, { mode: 'merge' }) + + const onDisk = await readFile(sourcePath('opencode.json'), 'utf8') + expect(onDisk).toContain('// theme comment') + expect(onDisk).not.toContain('"agent"') + }) + + it('keeps a comment that leads the next sibling inside the v1 container', async () => { + const raw = [ + '{', + ' "agent": {', + ' "reviewer": { "description": "Legacy reviewer" }, // keep', + ' "keeper": { "description": "Keeper" }', + ' }', + '}', + ].join('\n') + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const updated = await updateOpenCodeConfigFile( + { agents: { reviewer: { description: 'New reviewer' } } }, + { mode: 'merge' }, + ) + + expect(updated.content).toEqual({ + agent: { keeper: { description: 'Keeper' } }, + agents: { reviewer: { description: 'New reviewer' } }, + }) + const onDisk = await readFile(sourcePath('opencode.json'), 'utf8') + expect(onDisk).toContain('// keep') + expect(onDisk).not.toContain('Legacy reviewer') + }) + + it('removes both definitions when the v2 entity is patched with null', async () => { + const raw = JSON.stringify({ + agent: { reviewer: { description: 'Legacy reviewer' } }, + agents: { reviewer: { description: 'Current reviewer' } }, + }, null, 2) + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const updated = await updateOpenCodeConfigFile({ agents: { reviewer: null } }, { mode: 'merge' }) + + expect(updated.content).toEqual({ agents: {} }) + const onDisk = JSON.parse(await readFile(sourcePath('opencode.json'), 'utf8')) as Record + expect(onDisk).toEqual({ agents: {} }) + expect(onDisk).not.toHaveProperty('agent') + }) + + it('keeps the v2 definition when the same entity exists in both shapes', async () => { + const raw = JSON.stringify({ + agent: { reviewer: { description: 'Legacy reviewer' } }, + agents: { reviewer: { description: 'Current reviewer' } }, + }, null, 2) + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const updated = await updateOpenCodeConfigFile( + { agents: { reviewer: { description: 'Current reviewer' } } }, + { mode: 'merge' }, + ) + + expect(updated.content).toEqual({ agents: { reviewer: { description: 'Current reviewer' } } }) + const onDisk = await readFile(sourcePath('opencode.json'), 'utf8') + expect(JSON.parse(onDisk)).toEqual({ agents: { reviewer: { description: 'Current reviewer' } } }) + expect(onDisk).not.toContain('Legacy reviewer') + }) + + it('keeps other v1 entities in the container', async () => { + const raw = JSON.stringify({ + agent: { + reviewer: { description: 'Legacy reviewer' }, + keeper: { description: 'Keeper' }, + }, + }, null, 2) + await writeFile(sourcePath('opencode.json'), raw, 'utf8') + + const updated = await updateOpenCodeConfigFile( + { agents: { reviewer: { description: 'New reviewer' } } }, + { mode: 'merge' }, + ) + + expect(updated.content).toEqual({ + agent: { keeper: { description: 'Keeper' } }, + agents: { reviewer: { description: 'New reviewer' } }, + }) + const onDisk = JSON.parse(await readFile(sourcePath('opencode.json'), 'utf8')) as Record + expect(onDisk).toHaveProperty('agent.keeper') + }) + + it('rejects the save when the v1 entity only a lower-priority source defines', async () => { + const lower = JSON.stringify({ agent: { reviewer: { description: 'Legacy reviewer' } } }) + const target = '{"theme":"dark"}' + await writeFile(sourcePath('opencode.json'), lower, 'utf8') + await writeFile(sourcePath('opencode.jsonc'), target, 'utf8') + + const error = await updateOpenCodeConfigFile( + { agents: { reviewer: { description: 'New reviewer' } } }, + { mode: 'merge' }, + ).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(OpenCodeConfigShadowedRemovalError) + expect((error as OpenCodeConfigShadowedRemovalError).paths).toEqual(['agent.reviewer']) + expect((error as OpenCodeConfigShadowedRemovalError).sources).toEqual(['opencode.json']) + await expect(readFile(sourcePath('opencode.jsonc'), 'utf8')).resolves.toBe(target) + }) + }) + describe('restoreLegacyOpenCodeConfigBackup', () => { const backupPath = (configPath: string) => `${configPath}.ocm-sandbox-backup` diff --git a/backend/test/services/opencode-config-provider-schema.test.ts b/backend/test/services/opencode-config-provider-schema.test.ts deleted file mode 100644 index b151e2d0d..000000000 --- a/backend/test/services/opencode-config-provider-schema.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { OpenCodeConfigSchema } from "@opencode-manager/shared/schemas"; - -describe("OpenCodeConfigSchema - provider api/npm round-trip", () => { - it("preserves a provider-level api URL through parse", () => { - const input = { - provider: { - "my-api": { - name: "My API", - api: "https://api.example.com/v1", - options: { baseURL: "https://api.example.com/v1" }, - }, - }, - }; - const parsed = OpenCodeConfigSchema.parse(input); - expect(parsed.provider?.["my-api"]?.api).toBe("https://api.example.com/v1"); - expect(parsed.provider?.["my-api"]?.options?.baseURL).toBe("https://api.example.com/v1"); - }); - - it("preserves a provider-level npm package through parse", () => { - const input = { - provider: { - "my-npm": { - name: "My NPM Provider", - npm: "@scope/opencode-provider", - }, - }, - }; - const parsed = OpenCodeConfigSchema.parse(input); - expect(parsed.provider?.["my-npm"]?.npm).toBe("@scope/opencode-provider"); - }); - - it("preserves whitelist and blacklist model filters through parse", () => { - const input = { - provider: { - openai: { - whitelist: ["gpt-4o", "gpt-4o-mini"], - blacklist: ["gpt-3.5"], - }, - }, - }; - const parsed = OpenCodeConfigSchema.parse(input); - expect(parsed.provider?.openai?.whitelist).toEqual(["gpt-4o", "gpt-4o-mini"]); - expect(parsed.provider?.openai?.blacklist).toEqual(["gpt-3.5"]); - }); - - it("round-trips a full provider-with-models config without losing api or npm", () => { - const input = { - "$schema": "https://opencode.ai/config.json", - provider: { - custom: { - name: "Custom", - api: "https://api.custom.example/v1", - npm: "custom-provider", - models: { - "custom-1": { - id: "custom-1", - name: "Custom 1", - limit: { context: 200000, output: 8192 }, - }, - }, - }, - }, - }; - const parsed = OpenCodeConfigSchema.parse(input); - const roundTripped = OpenCodeConfigSchema.parse(JSON.parse(JSON.stringify(parsed))); - expect(roundTripped.provider?.custom?.api).toBe("https://api.custom.example/v1"); - expect(roundTripped.provider?.custom?.npm).toBe("custom-provider"); - expect(roundTripped.provider?.custom?.models?.["custom-1"]?.id).toBe("custom-1"); - }); -}); - -describe("OpenCodeConfigSchema - native V2 fields", () => { - it("accepts native plugin entries with package objects", () => { - const input = { - plugins: ["plain-plugin", { package: "@scope/plugin", options: { enabled: true } }], - }; - const parsed = OpenCodeConfigSchema.parse(input); - expect(parsed.plugins).toEqual(input.plugins); - }); - - it("accepts a native permissions rule set", () => { - const input = { - permissions: [ - { action: "shell", resource: "*", effect: "allow" }, - { action: "read", resource: "src/**", effect: "deny" }, - ], - }; - const parsed = OpenCodeConfigSchema.parse(input); - expect(parsed.permissions).toEqual(input.permissions); - }); - - it("accepts skills as an array of paths or urls", () => { - const input = { skills: ["https://example.com/skill", "./skills"] }; - const parsed = OpenCodeConfigSchema.parse(input); - expect(parsed.skills).toEqual(input.skills); - }); - - it("still accepts the legacy skills object", () => { - const input = { skills: { paths: [".opencode/skills"], urls: [] } }; - const parsed = OpenCodeConfigSchema.parse(input); - expect(parsed.skills).toEqual(input.skills); - }); - - it("accepts a native mcp servers record", () => { - const input = { mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } } }; - const parsed = OpenCodeConfigSchema.parse(input); - expect(parsed.mcp).toEqual(input.mcp); - }); - - it("accepts native top-level fields alongside legacy ones", () => { - const input = { - $schema: "https://opencode.ai/config.json", - update: "notify", - snapshots: true, - username: "manager", - agents: { assistant: { mode: "primary" } }, - commands: { review: { description: "Review changes" } }, - providers: { custom: { npm: "custom-provider" } }, - media: { image: { max_width: 2048 } }, - references: { docs: { path: "./docs" } }, - worktree: { directory: "../worktrees" }, - websearch: { provider: "random" }, - warming: false, - compaction: { auto: true }, - experimental: { subagent_depth: 1 }, - tool_output: { max_lines: 500 }, - watcher: { ignore: ["node_modules"] }, - default_agent: "assistant", - agent: { assistant: { mode: "primary" } }, - }; - const parsed = OpenCodeConfigSchema.parse(input); - expect(parsed.update).toBe("notify"); - expect(parsed.snapshots).toBe(true); - expect(parsed.username).toBe("manager"); - expect(parsed.agents?.assistant?.mode).toBe("primary"); - expect(parsed.providers?.custom?.npm).toBe("custom-provider"); - expect(parsed.default_agent).toBe("assistant"); - }); - - it("rejects an unknown update mode", () => { - expect(() => OpenCodeConfigSchema.parse({ update: "sometimes" })).toThrow(); - }); -}); diff --git a/backend/test/services/opencode-config-redact.test.ts b/backend/test/services/opencode-config-redact.test.ts index 80234ff40..bb25eb590 100644 --- a/backend/test/services/opencode-config-redact.test.ts +++ b/backend/test/services/opencode-config-redact.test.ts @@ -96,7 +96,7 @@ describe('redactOpenCodeConfigFile', () => { expect(redacted.redactedPaths).toEqual([]) }) - it('does not mask server, provider, or model names that look like secret keys', () => { + it('does not mask server, provider, agent, command, or model names that look like secret keys', () => { const redacted = redactOpenCodeConfigFile( buildConfig({ mcp: { @@ -108,12 +108,14 @@ describe('redactOpenCodeConfigFile', () => { }, }, }, - provider: { + providers: { 'secret-provider': { apiKey: 'provider-secret', models: { 'token-model': { name: 'gpt' } }, }, }, + agents: { 'token-agent': { model: 'openai/gpt' } }, + commands: { 'secret-command': { template: 'hello' } }, }), ) @@ -127,16 +129,18 @@ describe('redactOpenCodeConfigFile', () => { }, }, }, - provider: { + providers: { 'secret-provider': { apiKey: '', models: { 'token-model': { name: 'gpt' } }, }, }, + agents: { 'token-agent': { model: 'openai/gpt' } }, + commands: { 'secret-command': { template: 'hello' } }, }) expect(redacted.redactedPaths).toEqual([ 'mcp.servers.github-token.headers.Authorization', - 'provider.secret-provider.apiKey', + 'providers.secret-provider.apiKey', ]) }) diff --git a/backend/test/services/opencode-config-references.test.ts b/backend/test/services/opencode-config-references.test.ts new file mode 100644 index 000000000..572ac3f98 --- /dev/null +++ b/backend/test/services/opencode-config-references.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { patchNamesOpenCodeConfigEntities, restoreOpenCodeConfigReferences } from '../../src/services/opencode-config-references' + +describe('patchNamesOpenCodeConfigEntities', () => { + it('is true only when a patch names agents, commands, providers or MCP servers', () => { + expect(patchNamesOpenCodeConfigEntities({ agents: { reviewer: { description: 'x' } } })).toBe(true) + expect(patchNamesOpenCodeConfigEntities({ mcp: { servers: { docs: null } } })).toBe(true) + expect(patchNamesOpenCodeConfigEntities({ theme: 'dark', permissions: [] })).toBe(false) + expect(patchNamesOpenCodeConfigEntities({ mcp: { timeout: 5 } })).toBe(false) + }) +}) + +describe('restoreOpenCodeConfigReferences', () => { + it('keeps unchanged references of a v2 entity and writes edited values', () => { + const raw = { + agents: { reviewer: { system: '{file:./prompt.md}', description: 'old' } }, + providers: { acme: { settings: { apiKey: '{env:ACME_KEY}' } } }, + } + const resolved = { + agents: { reviewer: { system: 'Prompt text', description: 'old' } }, + providers: { acme: { settings: { apiKey: 'secret' } } }, + } + const patch = { + agents: { reviewer: { system: 'Prompt text', description: 'new' } }, + providers: { acme: { settings: { apiKey: 'secret' } } }, + } + + expect(restoreOpenCodeConfigReferences(patch, resolved, raw)).toEqual({ + agents: { reviewer: { system: '{file:./prompt.md}', description: 'new' } }, + providers: { acme: { settings: { apiKey: '{env:ACME_KEY}' } } }, + }) + }) + + it('writes an edited value over a reference', () => { + const raw = { agents: { reviewer: { system: '{file:./prompt.md}' } } } + const resolved = { agents: { reviewer: { system: 'Prompt text' } } } + const patch = { agents: { reviewer: { system: 'Rewritten prompt' } } } + + expect(restoreOpenCodeConfigReferences(patch, resolved, raw)).toEqual(patch) + }) + + it('carries references across a rename', () => { + const raw = { commands: { old: { template: '{file:./cmd.md}' } } } + const resolved = { commands: { old: { template: 'Run it' } } } + const patch = { commands: { renamed: { template: 'Run it' }, old: null } } + + expect(restoreOpenCodeConfigReferences(patch, resolved, raw)).toEqual({ + commands: { renamed: { template: '{file:./cmd.md}' }, old: null }, + }) + }) + + it('restores references from the v1 definition through renamed fields', () => { + const raw = { + agent: { reviewer: { prompt: '{file:./prompt.md}' } }, + provider: { acme: { options: { apiKey: '{env:ACME_KEY}', headers: { Authorization: 'Bearer {env:TOKEN}' } } } }, + mcp: { docs: { type: 'remote', url: 'https://x', headers: { key: '{env:MCP_KEY}' } } }, + } + const resolved = { + agents: { reviewer: { system: 'Prompt text' } }, + providers: { acme: { settings: { apiKey: 'secret' }, headers: { Authorization: 'Bearer t' } } }, + mcp: { servers: { docs: { type: 'remote', url: 'https://x', headers: { key: 'm' } } } }, + } + const patch = { + agents: { reviewer: { system: 'Prompt text', description: 'added' } }, + providers: { acme: { settings: { apiKey: 'secret' }, headers: { Authorization: 'Bearer t' } } }, + mcp: { servers: { docs: { type: 'remote', url: 'https://x', headers: { key: 'm' } } } }, + } + + expect(restoreOpenCodeConfigReferences(patch, resolved, raw)).toEqual({ + agents: { reviewer: { system: '{file:./prompt.md}', description: 'added' } }, + providers: { acme: { settings: { apiKey: '{env:ACME_KEY}' }, headers: { Authorization: 'Bearer {env:TOKEN}' } } }, + mcp: { servers: { docs: { type: 'remote', url: 'https://x', headers: { key: '{env:MCP_KEY}' } } } }, + }) + }) + + it('leaves new entities and non-entity keys unchanged', () => { + const patch = { agents: { fresh: { system: 'x' } }, theme: 'dark' } + + expect(restoreOpenCodeConfigReferences(patch, { agents: {} }, { agents: {} })).toEqual(patch) + }) +}) diff --git a/backend/test/services/opencode-config-validation.test.ts b/backend/test/services/opencode-config-validation.test.ts new file mode 100644 index 000000000..32b11b08e --- /dev/null +++ b/backend/test/services/opencode-config-validation.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { validateOpenCodeConfig } from '@opencode-manager/shared/opencode/config-validation' + +describe('validateOpenCodeConfig', () => { + it('accepts a valid v2 config document', () => { + expect( + validateOpenCodeConfig({ + $schema: 'https://opencode.ai/config.json', + model: 'anthropic/claude-sonnet-4', + share: 'manual', + agents: { assistant: { mode: 'primary' } }, + }), + ).toEqual({ success: true }) + }) + + it('accepts v2-only fields', () => { + expect( + validateOpenCodeConfig({ + shell: '/bin/zsh', + lsp: true, + timezone: 'Europe/Madrid', + policies: [{ action: 'tool.use', resource: 'bash', effect: 'deny' }], + }), + ).toEqual({ success: true }) + }) + + it('rejects invalid nested values with dotted paths', () => { + const result = validateOpenCodeConfig({ agents: { a: { mode: 'boss' } } }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.issues.map((issue) => issue.path)).toContain('agents.a.mode') + }) + + it('rejects invalid enum values', () => { + const result = validateOpenCodeConfig({ share: 'sometimes' }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.issues).toEqual([ + expect.objectContaining({ path: 'share' }), + ]) + }) + + it('accepts v1-only keys because unknown keys are ignored', () => { + expect( + validateOpenCodeConfig({ + agent: { build: { mode: 'primary' } }, + command: { deploy: { template: 'ship it' } }, + provider: { openai: { options: {} } }, + permission: { edit: 'allow' }, + }), + ).toEqual({ success: true }) + }) + + it('reports the legacy v1 skills object with an actionable message', () => { + const result = validateOpenCodeConfig({ skills: { paths: ['/home/user/skills'] } }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.issues).toEqual([ + { + path: 'skills', + message: + 'skills must be a list of skill paths. Replace the legacy `skills: { paths: [...] }` object with `skills: [...]`.', + }, + ]) + }) + + it('accepts the v2 skills array', () => { + expect(validateOpenCodeConfig({ skills: ['/home/user/skills'] })).toEqual({ success: true }) + }) +}) diff --git a/backend/test/services/opencode-event-routing.test.ts b/backend/test/services/opencode-event-routing.test.ts new file mode 100644 index 000000000..e20c00c2d --- /dev/null +++ b/backend/test/services/opencode-event-routing.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import type { V2Event } from '@opencode-manager/shared/opencode' +import { buildSessionStatusReplayEvent, createReplayEventBuilder, routeOpenCodeEvent } from '../../src/services/opencode-event-routing' + +const locatedStatus = { + id: 'evt_status_located', + created: 1, + type: 'session.status', + location: { directory: '/work/alpha' }, + data: { sessionID: 'ses_1', status: { type: 'busy' } }, +} satisfies V2Event + +const unlocatedStatus = { + id: 'evt_status_unlocated', + created: 2, + type: 'session.status', + data: { sessionID: 'ses_1', status: { type: 'idle' } }, +} satisfies V2Event + +const forkedSession = { + id: 'evt_forked', + created: 3, + type: 'session.forked', + durable: { aggregateID: 'ses_1', seq: 1, version: 2 }, + data: { + sessionID: 'ses_child', + parentID: 'ses_1', + boundary: { type: 'before', messageID: 'msg_1' }, + }, +} satisfies V2Event + +const childStatus = { + id: 'evt_status_child', + created: 4, + type: 'session.status', + data: { sessionID: 'ses_child', status: { type: 'busy' } }, +} satisfies V2Event + +const deletedSession = { + id: 'evt_deleted', + created: 5, + type: 'session.deleted', + durable: { aggregateID: 'ses_child', seq: 2, version: 2 }, + data: { sessionID: 'ses_child' }, +} satisfies V2Event + +describe('routeOpenCodeEvent', () => { + it('remembers the directory of a located event by session', () => { + const directories = new Map() + + const routed = routeOpenCodeEvent(locatedStatus, directories) + + expect(routed.event).toBe(locatedStatus) + expect(routed.directory).toBe('/work/alpha') + expect(routed.resolvedDirectory).toBe('/work/alpha') + expect(directories.get('ses_1')).toBe('/work/alpha') + }) + + it('resolves a session directory remembered from an earlier event', () => { + const directories = new Map([['ses_1', '/work/alpha']]) + + const routed = routeOpenCodeEvent(unlocatedStatus, directories) + + expect(routed.directory).toBeUndefined() + expect(routed.resolvedDirectory).toBe('/work/alpha') + }) + + it('inherits the parent directory for a forked session', () => { + const directories = new Map([['ses_1', '/work/alpha']]) + + const routed = routeOpenCodeEvent(forkedSession, directories) + + expect(routed.directory).toBeUndefined() + expect(routed.resolvedDirectory).toBe('/work/alpha') + expect(directories.get('ses_child')).toBe('/work/alpha') + }) + + it('forgets the directory of a deleted session', () => { + const directories = new Map([['ses_child', '/work/alpha']]) + + const routed = routeOpenCodeEvent(deletedSession, directories) + expect(routed.resolvedDirectory).toBe('/work/alpha') + expect(directories.has('ses_child')).toBe(false) + + const afterDelete = routeOpenCodeEvent(childStatus, directories) + expect(afterDelete.resolvedDirectory).toBeUndefined() + expect(directories.has('ses_child')).toBe(false) + }) +}) + +describe('createReplayEventBuilder', () => { + it('builds typed replay events with incremental ids', () => { + const buildReplayEvent = createReplayEventBuilder('replay_') + + const permission = buildReplayEvent('permission.asked', '/work/alpha', { + id: 'perm_1', + sessionID: 'ses_1', + action: 'edit', + resources: ['/work/alpha/file.ts'], + }) + const form = buildReplayEvent('form.created', '/work/alpha', { + form: { + id: 'form_1', + sessionID: 'ses_1', + title: 'Question', + fields: [{ key: 'answer', type: 'string' }], + }, + }) + const status = buildReplayEvent('session.status', '/work/alpha', { + sessionID: 'ses_1', + status: { type: 'idle' }, + }) + + expect(permission.type).toBe('permission.asked') + expect(permission.location?.directory).toBe('/work/alpha') + expect(form.type).toBe('form.created') + expect(status.type).toBe('session.status') + expect([permission.id, form.id, status.id]).toEqual(['replay_1', 'replay_2', 'replay_3']) + }) +}) + +describe('buildSessionStatusReplayEvent', () => { + it('builds idle and busy session status replays through the hub builder', () => { + const buildEvent = createReplayEventBuilder('ocm_replay_') + + const idle = buildSessionStatusReplayEvent(buildEvent, '/work/alpha', 'ses_1', 'idle') + const busy = buildSessionStatusReplayEvent(buildEvent, '/work/beta', 'ses_2', 'busy') + + expect(idle).toEqual({ + id: 'ocm_replay_1', + created: expect.any(Number), + type: 'session.status', + location: { directory: '/work/alpha' }, + data: { sessionID: 'ses_1', status: { type: 'idle' } }, + }) + expect(busy).toEqual({ + id: 'ocm_replay_2', + created: expect.any(Number), + type: 'session.status', + location: { directory: '/work/beta' }, + data: { sessionID: 'ses_2', status: { type: 'busy' } }, + }) + }) + + it('keeps the directory and id prefix owned by the caller', () => { + const buildEvent = createReplayEventBuilder('ocm_replay_remote_') + + const event = buildSessionStatusReplayEvent(buildEvent, '', 'ses_1', 'busy') + + expect(event.id).toBe('ocm_replay_remote_1') + expect(event.location?.directory).toBe('') + }) +}) diff --git a/backend/test/services/opencode-event-subscription.test.ts b/backend/test/services/opencode-event-subscription.test.ts new file mode 100644 index 000000000..c7b3faa78 --- /dev/null +++ b/backend/test/services/opencode-event-subscription.test.ts @@ -0,0 +1,377 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +import { ClientError, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' +import { subscribeOpenCodeEvents } from '../../src/services/opencode-event-subscription' + +const serverConnected = { + id: 'evt_connected', + type: 'server.connected', + data: {}, +} satisfies V2Event + +const sessionBusy = { + id: 'evt_busy', + created: 1, + type: 'session.status', + data: { sessionID: 'ses_1', status: { type: 'busy' } }, +} satisfies V2Event + +interface ControlledEventStream { + iterable: AsyncIterable + signal: AbortSignal + push: (event: V2Event) => void + end: () => void + fail: (error: unknown) => void +} + +interface FakeSubscription { + stream: ControlledEventStream + onActivity: (() => void) | undefined +} + +function createControlledEventStream(): ControlledEventStream { + const queue: V2Event[] = [] + let finished = false + let failure: { value: unknown } | null = null + let signal: AbortSignal + let notify: (() => void) | null = null + + const wake = (): void => { + const current = notify + notify = null + current?.() + } + + const iterable: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + async next() { + for (;;) { + if (queue.length > 0) return { value: queue.shift()!, done: false } + if (failure) { + const current = failure.value + failure = null + throw current + } + if (finished) return { value: undefined, done: true } + await new Promise((resolve) => { + notify = resolve + }) + } + }, + }), + } + + return { + iterable, + get signal() { + return signal + }, + set signal(next: AbortSignal) { + signal = next + next.addEventListener('abort', () => { + failure = { value: next.reason ?? new Error('aborted') } + wake() + }, { once: true }) + }, + push: (event) => { + queue.push(event) + wake() + }, + end: () => { + finished = true + wake() + }, + fail: (error) => { + failure = { value: error } + wake() + }, + } as ControlledEventStream +} + +function createFakeApi(): { api: OpenCodeApi; subscriptions: FakeSubscription[] } { + const subscriptions: FakeSubscription[] = [] + const api = { + event: { + subscribe: (options: { signal?: AbortSignal; onActivity?: () => void }) => { + const stream = createControlledEventStream() + stream.signal = options.signal! + subscriptions.push({ stream, onActivity: options.onActivity }) + return stream.iterable + }, + }, + } as unknown as OpenCodeApi + return { api, subscriptions } +} + +const flush = async (): Promise => { + await vi.advanceTimersByTimeAsync(0) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +describe('subscribeOpenCodeEvents', () => { + it('opens once on server.connected and forwards events in order', async () => { + const { api, subscriptions } = createFakeApi() + const onOpen = vi.fn() + const received: string[] = [] + const onDisconnect = vi.fn() + + const subscription = subscribeOpenCodeEvents({ + api: () => api, + label: 'test', + onOpen, + onEvent: (event) => { + received.push(event.type) + }, + onDisconnect, + }) + + expect(subscriptions).toHaveLength(1) + + subscriptions[0]!.stream.push(serverConnected) + subscriptions[0]!.stream.push(sessionBusy) + await flush() + + expect(onOpen).toHaveBeenCalledTimes(1) + expect(received).toEqual(['server.connected', 'session.status']) + expect(onDisconnect).not.toHaveBeenCalled() + + subscription.close() + }) + + it('keeps streaming when an event handler throws', async () => { + const { api, subscriptions } = createFakeApi() + const received: string[] = [] + const onDisconnect = vi.fn() + + const subscription = subscribeOpenCodeEvents({ + api: () => api, + label: 'test', + onOpen: () => undefined, + onEvent: (event) => { + received.push(event.type) + if (event.type === 'server.connected') throw new Error('handler failure') + }, + onDisconnect, + }) + + subscriptions[0]!.stream.push(serverConnected) + subscriptions[0]!.stream.push(sessionBusy) + await flush() + + expect(received).toEqual(['server.connected', 'session.status']) + expect(onDisconnect).not.toHaveBeenCalled() + + subscription.close() + }) + + it('reconnects after the stream ends once the reconnect delay elapses', async () => { + const { api, subscriptions } = createFakeApi() + const onOpen = vi.fn() + const onDisconnect = vi.fn() + + const subscription = subscribeOpenCodeEvents({ + api: () => api, + label: 'test', + onOpen, + onEvent: () => undefined, + onDisconnect, + reconnectDelayMs: 100, + maxReconnectDelayMs: 1000, + }) + + subscriptions[0]!.stream.push(serverConnected) + await flush() + expect(onOpen).toHaveBeenCalledTimes(1) + + subscriptions[0]!.stream.end() + await flush() + expect(onDisconnect).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(99) + expect(subscriptions).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(1) + expect(subscriptions).toHaveLength(2) + + subscriptions[1]!.stream.push(serverConnected) + await flush() + expect(onOpen).toHaveBeenCalledTimes(2) + + subscription.close() + }) + + it('keeps reconnecting when the disconnect handler throws', async () => { + const { api, subscriptions } = createFakeApi() + const onOpen = vi.fn() + const onDisconnect = vi.fn(() => { + throw new Error('disconnect failure') + }) + + const subscription = subscribeOpenCodeEvents({ + api: () => api, + label: 'test', + onOpen, + onEvent: () => undefined, + onDisconnect, + reconnectDelayMs: 100, + maxReconnectDelayMs: 1000, + }) + + subscriptions[0]!.stream.push(serverConnected) + await flush() + expect(onOpen).toHaveBeenCalledTimes(1) + + subscriptions[0]!.stream.end() + await flush() + expect(onDisconnect).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(100) + expect(subscriptions).toHaveLength(2) + + subscriptions[1]!.stream.push(serverConnected) + await flush() + expect(onOpen).toHaveBeenCalledTimes(2) + + subscription.close() + }) + + it('doubles the reconnect delay after a thrown transport error and caps it', async () => { + const { api, subscriptions } = createFakeApi() + const onDisconnect = vi.fn() + + const subscription = subscribeOpenCodeEvents({ + api: () => api, + label: 'test', + onOpen: () => undefined, + onEvent: () => undefined, + onDisconnect, + reconnectDelayMs: 100, + maxReconnectDelayMs: 150, + }) + + subscriptions[0]!.stream.fail(new ClientError('Transport')) + await flush() + expect(onDisconnect).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(99) + expect(subscriptions).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + expect(subscriptions).toHaveLength(2) + + subscriptions[1]!.stream.fail(new ClientError('UnexpectedStatus')) + await flush() + expect(onDisconnect).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(149) + expect(subscriptions).toHaveLength(2) + await vi.advanceTimersByTimeAsync(1) + expect(subscriptions).toHaveLength(3) + + subscription.close() + }) + + it('aborts a stalled attempt and starts a new one', async () => { + const { api, subscriptions } = createFakeApi() + const onDisconnect = vi.fn() + + const subscription = subscribeOpenCodeEvents({ + api: () => api, + label: 'test', + onOpen: () => undefined, + onEvent: () => undefined, + onDisconnect, + reconnectDelayMs: 10, + stallThresholdMs: 100, + watchdogTickMs: 50, + }) + + subscriptions[0]!.stream.push(serverConnected) + await flush() + + await vi.advanceTimersByTimeAsync(100) + expect(subscriptions[0]!.stream.signal.aborted).toBe(false) + + await vi.advanceTimersByTimeAsync(50) + expect(subscriptions[0]!.stream.signal.aborted).toBe(true) + expect(onDisconnect).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10) + expect(subscriptions).toHaveLength(2) + + subscription.close() + }) + + it('stops reconnecting after close', async () => { + const { api, subscriptions } = createFakeApi() + const onDisconnect = vi.fn() + + const subscription = subscribeOpenCodeEvents({ + api: () => api, + label: 'test', + onOpen: () => undefined, + onEvent: () => undefined, + onDisconnect, + reconnectDelayMs: 100, + }) + + subscriptions[0]!.stream.push(serverConnected) + await flush() + subscriptions[0]!.stream.end() + await flush() + expect(onDisconnect).toHaveBeenCalledTimes(1) + + subscription.close() + await vi.advanceTimersByTimeAsync(10_000) + + expect(subscriptions).toHaveLength(1) + }) + + it('reconnect aborts the current attempt and restarts with the base delay', async () => { + const { api, subscriptions } = createFakeApi() + + const subscription = subscribeOpenCodeEvents({ + api: () => api, + label: 'test', + onOpen: () => undefined, + onEvent: () => undefined, + onDisconnect: () => undefined, + reconnectDelayMs: 100, + maxReconnectDelayMs: 1000, + }) + + subscriptions[0]!.stream + .fail(new ClientError('Transport')) + await flush() + await vi.advanceTimersByTimeAsync(100) + expect(subscriptions).toHaveLength(2) + + subscription.reconnect() + expect(subscriptions).toHaveLength(3) + expect(subscriptions[1]!.stream.signal.aborted).toBe(true) + + subscriptions[2]!.stream.end() + await flush() + await vi.advanceTimersByTimeAsync(99) + expect(subscriptions).toHaveLength(3) + await vi.advanceTimersByTimeAsync(1) + expect(subscriptions).toHaveLength(4) + + subscription.close() + }) +}) diff --git a/backend/test/services/opencode-import.test.ts b/backend/test/services/opencode-import.test.ts index 756d805d9..0bac4a3a6 100644 --- a/backend/test/services/opencode-import.test.ts +++ b/backend/test/services/opencode-import.test.ts @@ -86,8 +86,8 @@ describe('opencode-import service', () => { const jsoncPath = path.join(os.homedir(), '.config', 'opencode', 'opencode.jsonc') mockExistsSync.mockImplementation((candidate: string) => [jsonPath, jsoncPath].includes(candidate)) mockFileExists.mockResolvedValue(false) - const rawJson = '{"model":"lower"}' - const rawJsonc = '{\n// keep\n"model":"higher"\n}' + const rawJson = '{"model":"test/lower"}' + const rawJsonc = '{\n// keep\n"model":"test/higher"\n}' mockReadFileContent.mockImplementation(async (candidate: string) => candidate === jsoncPath ? rawJsonc : rawJson) const result = await syncOpenCodeImport({}) @@ -115,7 +115,7 @@ describe('opencode-import service', () => { const legacyPath = path.join(os.homedir(), '.config', 'opencode', 'config.json') mockExistsSync.mockImplementation((candidate: string) => [jsonPath, legacyPath].includes(candidate)) mockFileExists.mockResolvedValue(false) - const rawJson = '{"model":"lower"}' + const rawJson = '{"model":"test/lower"}' const rawLegacy = '{"theme":"dark"}' mockReadFileContent.mockImplementation(async (candidate: string) => candidate === legacyPath ? rawLegacy : rawJson) diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index 39808c733..1f2c4d62c 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -3242,6 +3242,24 @@ describe('OpenCodeServerManager - server auth', () => { expect(manager.isRestartPending()).toBe(false) }) + it('notifies lifecycle listeners only when the gate opens', async () => { + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + const listener = vi.fn() + const unsubscribe = manager.onLifecycleInitialized(listener) + + manager.setLifecycleInitialized(false) + manager.setLifecycleInitialized(true) + manager.setLifecycleInitialized(true) + manager.setLifecycleInitialized(false) + manager.setLifecycleInitialized(true) + unsubscribe() + manager.setLifecycleInitialized(false) + manager.setLifecycleInitialized(true) + + expect(listener).toHaveBeenCalledTimes(2) + }) + function setOpenCodeEnv(values: { host: string; password: string }) { Object.defineProperty(ENV.OPENCODE, 'HOST', { value: values.host, configurable: true, writable: true }) Object.defineProperty(ENV.OPENCODE, 'SERVER_PASSWORD', { value: values.password, configurable: true, writable: true }) diff --git a/backend/test/services/opencode/client.test.ts b/backend/test/services/opencode/client.test.ts index 0587efbbc..abeca5c50 100644 --- a/backend/test/services/opencode/client.test.ts +++ b/backend/test/services/opencode/client.test.ts @@ -36,6 +36,11 @@ import { createOpenCodeClient, FetchOpenCodeClient } from '../../../src/services import { ENV } from '@opencode-manager/shared/config/env' import { openCodeLocation } from '@opencode-manager/shared/opencode' +async function readBodyText(body: RequestInit['body']): Promise { + if (!(body instanceof ReadableStream)) return null + return await new Response(body).text() +} + describe('OpenCodeClient', () => { const baseUrl = 'http://127.0.0.1:5551' const basicAuth = 'Basic dXNlcjpwYXNz' @@ -91,9 +96,9 @@ describe('OpenCodeClient', () => { expect((capturedInit?.headers as Record)['x-opencode-directory']).toBe('%2Frepo%2Fb') }) - it('should read body for POST but not for GET/HEAD', async () => { + it('should stream the body for POST but not for GET/HEAD', async () => { const mockResponse = new Response(JSON.stringify({}), { status: 200 }) - const capturedBodies: unknown[] = [] + const capturedBodies: Array = [] const fetchFn = async (_: URL | Request | string, init?: RequestInit) => { capturedBodies.push(init?.body) return mockResponse @@ -119,7 +124,8 @@ describe('OpenCodeClient', () => { await client.forwardRaw(headRequest) - expect(capturedBodies[0]).toBe('test body') + expect(capturedBodies[0]).toBeInstanceOf(ReadableStream) + expect(await readBodyText(capturedBodies[0])).toBe('test body') expect(capturedBodies[1]).toBeUndefined() expect(capturedBodies[2]).toBeUndefined() }) @@ -177,7 +183,9 @@ describe('OpenCodeClient', () => { expect(capturedUrl?.toString()).toBe(baseUrl + '/config?foo=bar') expect(capturedInit?.method).toBe('POST') - expect(capturedInit?.body).toBe('request-body-content') + expect(capturedInit?.body).toBeInstanceOf(ReadableStream) + expect(await readBodyText(capturedInit?.body)).toBe('request-body-content') + expect(capturedInit?.duplex).toBe('half') expect(capturedInit?.headers).toEqual(expect.objectContaining({ Authorization: basicAuth, 'x-caller': 'caller-value', diff --git a/backend/test/services/remote-server-events.test.ts b/backend/test/services/remote-server-events.test.ts index 97248a439..38f64ca73 100644 --- a/backend/test/services/remote-server-events.test.ts +++ b/backend/test/services/remote-server-events.test.ts @@ -8,15 +8,10 @@ vi.mock('../../src/utils/logger', () => ({ }, })) -import type { OpenCodeApi } from '@opencode-manager/shared/opencode' +import type { OpenCodeApi, V2Event } from '@opencode-manager/shared/opencode' import type { OpenCodeServer, OpenCodeServerTestResult } from '@opencode-manager/shared/schemas' import { sseAggregator } from '../../src/services/sse-aggregator' -import { - RemoteServerEventHub, - type RemoteEventSource, - type RemoteEventSourceFactory, - type RemoteEventSourceInit, -} from '../../src/services/remote-server-events' +import { RemoteServerEventHub } from '../../src/services/remote-server-events' import type { OpenCodeServerChange, OpenCodeServerConnection, @@ -45,32 +40,73 @@ const SERVER_B: OpenCodeServer = { updatedAt: 1, } -class FakeEventSource implements RemoteEventSource { - onopen: ((event: unknown) => void) | null = null - onerror: ((event: unknown) => void) | null = null - onmessage: ((event: { data: string }) => void) | null = null - closed = false +const SERVER_CONNECTED: V2Event = { id: 'evt_connected', type: 'server.connected', data: {} } - constructor( - readonly url: string, - readonly init?: RemoteEventSourceInit, - ) {} - - close(): void { - this.closed = true - } +interface ControlledEventStream { + iterable: AsyncIterable + signal: AbortSignal + push: (event: V2Event) => void + end: () => void + fail: (error: unknown) => void +} - open(): void { - this.onopen?.({}) +function createControlledEventStream(): ControlledEventStream { + const queue: V2Event[] = [] + let finished = false + let failure: { value: unknown } | null = null + let signal: AbortSignal + let notify: (() => void) | null = null + + const wake = (): void => { + const current = notify + notify = null + current?.() } - fail(event: unknown = { message: 'connection lost' }): void { - this.onerror?.(event) + const iterable: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + async next() { + for (;;) { + if (queue.length > 0) return { value: queue.shift()!, done: false } + if (failure) { + const current = failure.value + failure = null + throw current + } + if (finished) return { value: undefined, done: true } + await new Promise((resolve) => { + notify = resolve + }) + } + }, + }), } - message(data: string): void { - this.onmessage?.({ data }) - } + return { + iterable, + get signal() { + return signal + }, + set signal(next: AbortSignal) { + signal = next + next.addEventListener('abort', () => { + failure = { value: next.reason ?? new Error('aborted') } + wake() + }, { once: true }) + }, + push: (event) => { + queue.push(event) + wake() + }, + end: () => { + finished = true + wake() + }, + fail: (error) => { + failure = { value: error } + wake() + }, + } as ControlledEventStream } interface CapturedClient { @@ -107,10 +143,18 @@ interface ApiFixture { sessionDirectories?: Record } -function makeApi(fixture: ApiFixture = {}): OpenCodeApi { +function makeApi(fixture: ApiFixture, subscriptions: ControlledEventStream[]): OpenCodeApi { const directoryOf = (input?: { location?: { directory?: string } }): string => input?.location?.directory ?? '' return { + event: { + subscribe: (options: { signal?: AbortSignal; onActivity?: () => void }) => { + const stream = createControlledEventStream() + stream.signal = options.signal! + subscriptions.push(stream) + return stream.iterable + }, + }, permission: { request: { list: vi.fn(async (input?: { location?: { directory?: string } }) => ({ @@ -134,21 +178,40 @@ function makeApi(fixture: ApiFixture = {}): OpenCodeApi { } as unknown as OpenCodeApi } +interface FakeServerTransport { + api: OpenCodeApi + subscriptions: ControlledEventStream[] +} + interface FakeRegistry { registry: OpenCodeServerRegistry api: OpenCodeApi + transport: (serverId: string) => FakeServerTransport + latest: (serverId: string) => ControlledEventStream emit: (change: OpenCodeServerChange) => void setServer: (server: OpenCodeServer) => void removeServer: (serverId: string) => void listCalls: () => number + clientCalls: string[] } function createRegistry(servers: OpenCodeServer[], fixture: ApiFixture = {}): FakeRegistry { const byId = new Map(servers.map(server => [server.id, server])) const listeners = new Set<(change: OpenCodeServerChange) => void>() - const api = makeApi(fixture) + const transports = new Map() + const clientCalls: string[] = [] let listCalls = 0 + const transport = (serverId: string): FakeServerTransport => { + const existing = transports.get(serverId) + if (existing) return existing + + const subscriptions: ControlledEventStream[] = [] + const created: FakeServerTransport = { api: makeApi(fixture, subscriptions), subscriptions } + transports.set(serverId, created) + return created + } + const registry = { list: () => { listCalls += 1 @@ -164,7 +227,10 @@ function createRegistry(servers: OpenCodeServer[], fixture: ApiFixture = {}): Fa authorization: server.hasPassword ? 'Basic dXNlcjpwYXNz' : null, } }, - getClient: () => ({ api }), + getClient: (serverId: string) => { + clientCalls.push(serverId) + return { api: transport(serverId).api } + }, onChange: (listener: (change: OpenCodeServerChange) => void) => { listeners.add(listener) return () => { listeners.delete(listener) } @@ -174,25 +240,19 @@ function createRegistry(servers: OpenCodeServer[], fixture: ApiFixture = {}): Fa return { registry, - api, + api: transport(servers[0]!.id).api, + transport, + latest: (serverId) => { + const subscriptions = transport(serverId).subscriptions + const latest = subscriptions[subscriptions.length - 1] + if (!latest) throw new Error(`No subscription for ${serverId}`) + return latest + }, emit: (change) => { listeners.forEach(listener => listener(change)) }, setServer: (server) => { byId.set(server.id, server) }, removeServer: (serverId) => { byId.delete(serverId) }, listCalls: () => listCalls, - } -} - -function createSourceFactory() { - const sources: FakeEventSource[] = [] - const factory: RemoteEventSourceFactory = (url, init) => { - const source = new FakeEventSource(url, init) - sources.push(source) - return source - } - return { - sources, - factory, - latest: () => sources[sources.length - 1]!, + clientCalls, } } @@ -202,28 +262,26 @@ async function flushPromises(): Promise { } } -function remoteEvent(type: string, data: unknown, directory?: string): string { - return JSON.stringify({ +function remoteEvent(type: string, data: unknown, directory?: string): V2Event { + return { id: 'evt_1', created: 1, type, ...(directory ? { location: { directory } } : {}), data, - }) + } as V2Event } describe('RemoteServerEventHub event delivery', () => { let hub: RemoteServerEventHub let fake: FakeRegistry - let sources: ReturnType beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) fake = createRegistry([SERVER_A, SERVER_B]) - sources = createSourceFactory() - hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: sources.factory }) + hub = new RemoteServerEventHub(fake.registry, sseAggregator) }) afterEach(() => { @@ -231,18 +289,15 @@ describe('RemoteServerEventHub event delivery', () => { sseAggregator.shutdown() }) - it('opens one stream per enabled server with the connection authorization', () => { + it('opens one subscription per enabled server through the registry client', () => { hub.start() - expect(sources.sources.map(source => source.url)).toEqual([ - 'https://studio.example.com/api/event', - 'https://laptop.example.com/api/event', - ]) - expect(sources.sources[0]?.init?.fetch).toBeTypeOf('function') - expect(sources.sources[1]?.init).toBeUndefined() + expect(fake.clientCalls).toEqual(['srv_a', 'srv_b']) + expect(fake.transport('srv_a').subscriptions).toHaveLength(1) + expect(fake.transport('srv_b').subscriptions).toHaveLength(1) }) - it('broadcasts a located event to every client with a serverId envelope', () => { + it('broadcasts a located event to every client with a serverId envelope', async () => { const remoteClient = createCapturingClient() const otherServerClient = createCapturingClient() const localClient = createCapturingClient() @@ -254,11 +309,15 @@ describe('RemoteServerEventHub event delivery', () => { sseAggregator.onEvent(listener) hub.start() - const source = sources.sources[0]! - source.open() + const stream = fake.latest('srv_a') + stream.push(SERVER_CONNECTED) + await flushPromises() remoteClient.frames.length = 0 + otherServerClient.frames.length = 0 + localClient.frames.length = 0 - source.message(remoteEvent('session.idle', { sessionID: 'ses_1' }, '/repo')) + stream.push(remoteEvent('session.idle', { sessionID: 'ses_1' }, '/repo')) + await flushPromises() const envelope = { directory: '/repo', @@ -278,16 +337,18 @@ describe('RemoteServerEventHub event delivery', () => { expect(sseAggregator.getActiveSessions()).toEqual({}) }) - it('resolves a directory-less event through the per-server session map', () => { + it('resolves a directory-less event through the per-server session map', async () => { const client = createCapturingClient() sseAggregator.addClient('session-map', client.callback, client.writeFrame, ['srv_a|/repo']) hub.start() - const source = sources.sources[0]! - source.message(remoteEvent('session.created', { sessionID: 'ses_1', parentID: null }, '/repo')) + const stream = fake.latest('srv_a') + stream.push(remoteEvent('session.created', { sessionID: 'ses_1', parentID: null }, '/repo')) + await flushPromises() client.frames.length = 0 - source.message(remoteEvent('session.execution.succeeded', { sessionID: 'ses_1' })) + stream.push(remoteEvent('session.execution.succeeded', { sessionID: 'ses_1' })) + await flushPromises() expect(client.frames.map(parseFrame)).toEqual([{ directory: '/repo', @@ -301,22 +362,26 @@ describe('RemoteServerEventHub event delivery', () => { }]) }) - it('inherits the parent directory for a forked session and forgets deleted sessions', () => { + it('inherits the parent directory for a forked session and forgets deleted sessions', async () => { const client = createCapturingClient() sseAggregator.addClient('fork', client.callback, client.writeFrame, ['srv_a|/repo']) hub.start() - const source = sources.sources[0]! - source.message(remoteEvent('session.created', { sessionID: 'ses_parent' }, '/repo')) - source.message(remoteEvent('session.forked', { sessionID: 'ses_fork', parentID: 'ses_parent' })) + const stream = fake.latest('srv_a') + stream.push(remoteEvent('session.created', { sessionID: 'ses_parent' }, '/repo')) + stream.push(remoteEvent('session.forked', { sessionID: 'ses_fork', parentID: 'ses_parent' })) + await flushPromises() client.frames.length = 0 - source.message(remoteEvent('session.execution.succeeded', { sessionID: 'ses_fork' })) + stream.push(remoteEvent('session.execution.succeeded', { sessionID: 'ses_fork' })) + await flushPromises() expect(client.frames.map(parseFrame).map(frame => frame.directory)).toEqual(['/repo']) - source.message(remoteEvent('session.deleted', { sessionID: 'ses_fork' })) + stream.push(remoteEvent('session.deleted', { sessionID: 'ses_fork' })) + await flushPromises() client.frames.length = 0 - source.message(remoteEvent('session.execution.succeeded', { sessionID: 'ses_fork' })) + stream.push(remoteEvent('session.execution.succeeded', { sessionID: 'ses_fork' })) + await flushPromises() expect(client.frames.map(parseFrame)).toEqual([{ directory: null, @@ -330,7 +395,7 @@ describe('RemoteServerEventHub event delivery', () => { }]) }) - it('broadcasts to every client when the directory is unknown', () => { + it('broadcasts to every client when the directory is unknown', async () => { const subscribedA = createCapturingClient() const otherDirectoryA = createCapturingClient() const subscribedB = createCapturingClient() @@ -339,7 +404,8 @@ describe('RemoteServerEventHub event delivery', () => { sseAggregator.addClient('any-b', subscribedB.callback, subscribedB.writeFrame, ['srv_b|/repo']) hub.start() - sources.sources[0]!.message(remoteEvent('server.connected', {})) + fake.latest('srv_a').push(remoteEvent('server.connected', {})) + await flushPromises() const envelope = { directory: null, @@ -351,46 +417,31 @@ describe('RemoteServerEventHub event delivery', () => { expect(subscribedB.frames.map(parseFrame)).toEqual([envelope]) }) - it('keeps the raw upstream payload in the remote envelope and re-serializes multi-line payloads', () => { + it('serializes the routed event into a single envelope data line', async () => { const client = createCapturingClient() sseAggregator.addClient('raw-remote', client.callback, client.writeFrame, ['srv_a|/repo']) hub.start() - const source = sources.sources[0]! - const raw = '{"type":"session.idle","id":"evt_raw","created":7,"location":{"directory":"/repo"},"data":{"sessionID":"ses_1","extra":{"b":2,"a":1}}}' - source.message(raw) - - expect(client.frames).toEqual([`event: message\ndata: {"directory":"/repo","serverId":"srv_a","payload":${raw}}\n\n`]) - - const multiLine = JSON.stringify(JSON.parse(raw), null, 2) - source.message(multiLine) - - expect(client.frames[1]!.split('\n').filter(line => line.startsWith('data: '))).toHaveLength(1) - expect(parseFrame(client.frames[1]!).payload).toEqual(JSON.parse(raw)) - }) - - it('ignores malformed remote payloads', () => { - const client = createCapturingClient() - sseAggregator.addClient('malformed-remote', client.callback, client.writeFrame, ['srv_a|/repo']) - - hub.start() - const source = sources.sources[0]! - source.message('not-json') - source.message(JSON.stringify({ id: 'evt', created: 1, data: {} })) + const event = remoteEvent('session.idle', { sessionID: 'ses_1', extra: { b: 2, a: 1 } }, '/repo') + fake.latest('srv_a').push(event) + await flushPromises() - expect(client.frames).toHaveLength(0) + expect(client.frames).toHaveLength(1) + expect(client.frames[0]!.split('\n').filter(line => line.startsWith('data: '))).toHaveLength(1) + expect(parseFrame(client.frames[0]!).payload).toEqual(event) }) - it('notifies server event listeners with the remote server id and resolved directory', () => { + it('notifies server event listeners with the remote server id and resolved directory', async () => { const seen: Array<{ serverId: string; directory: string | null; type: string }> = [] sseAggregator.onServerEvent((serverId, directory, event) => { seen.push({ serverId, directory, type: event.type }) }) hub.start() - const source = sources.sources[0]! - source.message(remoteEvent('session.created', { sessionID: 'ses_1' }, '/repo')) - source.message(remoteEvent('session.execution.succeeded', { sessionID: 'ses_1' })) + const stream = fake.latest('srv_a') + stream.push(remoteEvent('session.created', { sessionID: 'ses_1' }, '/repo')) + stream.push(remoteEvent('session.execution.succeeded', { sessionID: 'ses_1' })) + await flushPromises() expect(seen).toEqual([ { serverId: 'srv_a', directory: '/repo', type: 'session.created' }, @@ -399,29 +450,32 @@ describe('RemoteServerEventHub event delivery', () => { expect(sseAggregator.getActiveSessions()).toEqual({}) }) - it('tracks remote subagent sessions per server', () => { + it('tracks remote subagent sessions per server', async () => { hub.start() - const source = sources.sources[0]! + const stream = fake.latest('srv_a') - source.message(remoteEvent('session.created', { sessionID: 'ses_sub', parentID: 'ses_root' }, '/repo')) + stream.push(remoteEvent('session.created', { sessionID: 'ses_sub', parentID: 'ses_root' }, '/repo')) + await flushPromises() expect(hub.isSubagentSession('srv_a', 'ses_sub')).toBe(true) expect(sseAggregator.isSubagentSession('ses_sub', 'srv_a')).toBe(true) expect(sseAggregator.isSubagentSession('ses_sub')).toBe(false) expect(sseAggregator.isSubagentSession('ses_sub', 'srv_b')).toBe(false) - source.message(remoteEvent('session.deleted', { sessionID: 'ses_sub' })) + stream.push(remoteEvent('session.deleted', { sessionID: 'ses_sub' })) + await flushPromises() expect(hub.isSubagentSession('srv_a', 'ses_sub')).toBe(false) expect(sseAggregator.isSubagentSession('ses_sub', 'srv_a')).toBe(false) }) - it('notifies server connected listeners when a remote stream opens', () => { + it('notifies server connected listeners when a remote stream opens', async () => { const seen: string[] = [] sseAggregator.onServerConnected(serverId => { seen.push(serverId) }) hub.start() - sources.sources[1]!.open() + fake.latest('srv_b').push(SERVER_CONNECTED) + await flushPromises() expect(seen).toEqual(['srv_b']) }) @@ -430,16 +484,13 @@ describe('RemoteServerEventHub event delivery', () => { describe('RemoteServerEventHub reconnection', () => { let hub: RemoteServerEventHub let fake: FakeRegistry - let sources: ReturnType beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) fake = createRegistry([SERVER_A]) - sources = createSourceFactory() hub = new RemoteServerEventHub(fake.registry, sseAggregator, { - eventSourceFactory: sources.factory, reconnectDelayMs: 1000, maxReconnectDelayMs: 4000, }) @@ -453,22 +504,24 @@ describe('RemoteServerEventHub reconnection', () => { async function openThenReconnect(): Promise { hub.start() - sources.sources[0]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) + await flushPromises() + fake.latest('srv_a').fail(new Error('connection lost')) await flushPromises() - sources.sources[0]!.fail({ message: 'connection lost' }) await vi.advanceTimersByTimeAsync(1000) - sources.sources[1]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() } - it('sends a resync to the subscribers of the server on connect', () => { + it('sends a resync to the subscribers of the server on connect', async () => { const clientA = createCapturingClient() const clientB = createCapturingClient() sseAggregator.addClient('resync-a', clientA.callback, clientA.writeFrame, ['srv_a|/repo']) sseAggregator.addClient('resync-b', clientB.callback, clientB.writeFrame, ['srv_b|/repo']) hub.start() - sources.sources[0]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) + await flushPromises() const resyncs = clientA.events.filter(event => event.event === 'resync') expect(resyncs).toHaveLength(1) @@ -476,40 +529,69 @@ describe('RemoteServerEventHub reconnection', () => { expect(clientB.events.filter(event => event.event === 'resync')).toHaveLength(0) }) - it('closes the failed stream and reconnects with exponential backoff', async () => { + it('reconnects with exponential backoff after a stream error', async () => { vi.useFakeTimers() hub.start() - const first = sources.sources[0]! - first.open() + const first = fake.latest('srv_a') + first.push(SERVER_CONNECTED) await flushPromises() first.fail({ message: 'Unauthorized', code: 401 }) + await flushPromises() - expect(first.closed).toBe(true) expect(hub.getStatuses()).toEqual([ { serverId: 'srv_a', connected: false, version: '2.0.15', error: 'Unauthorized' }, ]) await vi.advanceTimersByTimeAsync(999) - expect(sources.sources).toHaveLength(1) + expect(fake.transport('srv_a').subscriptions).toHaveLength(1) await vi.advanceTimersByTimeAsync(1) - expect(sources.sources).toHaveLength(2) + expect(fake.transport('srv_a').subscriptions).toHaveLength(2) - const second = sources.sources[1]! + const second = fake.latest('srv_a') second.fail({ message: 'Unauthorized', code: 401 }) + await flushPromises() + + await vi.advanceTimersByTimeAsync(1000) + expect(fake.transport('srv_a').subscriptions).toHaveLength(2) await vi.advanceTimersByTimeAsync(1000) - expect(sources.sources).toHaveLength(2) + expect(fake.transport('srv_a').subscriptions).toHaveLength(3) + }) + + it('reconnects after the stream errors and after it ends', async () => { + vi.useFakeTimers() + hub.start() + const first = fake.latest('srv_a') + first.push(SERVER_CONNECTED) + await flushPromises() + + first.end() + await flushPromises() + expect(hub.getStatuses()[0]?.connected).toBe(false) await vi.advanceTimersByTimeAsync(1000) - expect(sources.sources).toHaveLength(3) + expect(fake.transport('srv_a').subscriptions).toHaveLength(2) + + const second = fake.latest('srv_a') + second.push(SERVER_CONNECTED) + await flushPromises() + expect(hub.getStatuses()[0]?.connected).toBe(true) + + second.fail(new Error('connection reset')) + await flushPromises() + await vi.advanceTimersByTimeAsync(2000) + + expect(fake.transport('srv_a').subscriptions).toHaveLength(3) + expect(hub.getStatuses()[0]?.error).toBe('connection reset') }) it('reports the version lazily from the registry without polling', async () => { hub.start() - sources.sources[0]!.open() + const stream = fake.latest('srv_a') + stream.push(SERVER_CONNECTED) await flushPromises() - sources.sources[0]!.open() + stream.push(SERVER_CONNECTED) await flushPromises() expect(fake.registry.test).toHaveBeenCalledTimes(1) @@ -548,7 +630,7 @@ describe('RemoteServerEventHub reconnection', () => { it('clears the version and probes again when the server is updated', async () => { hub.start() - sources.sources[0]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() expect(hub.getStatuses()[0]?.version).toBe('2.0.15') @@ -557,7 +639,7 @@ describe('RemoteServerEventHub reconnection', () => { expect(hub.getStatuses()[0]?.version).toBeNull() - sources.sources[1]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() expect(fake.registry.test).toHaveBeenCalledTimes(2) @@ -571,13 +653,13 @@ describe('RemoteServerEventHub reconnection', () => { })) hub.start() - sources.sources[0]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() expect(fake.registry.test).toHaveBeenCalledTimes(1) vi.mocked(fake.registry.test).mockResolvedValueOnce({ ok: true, version: '2.1.0', versionSupported: true, error: null }) fake.emit({ type: 'upserted', serverId: 'srv_a' }) - sources.sources[1]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() resolveFirstProbe({ ok: true, version: '1.0.0', versionSupported: true, error: null }) @@ -587,59 +669,60 @@ describe('RemoteServerEventHub reconnection', () => { expect(hub.getStatuses()[0]?.version).toBe('2.1.0') }) - it('closes and forgets the stream when the server is disabled', () => { + it('closes and forgets the stream when the server is disabled', async () => { const client = createCapturingClient() sseAggregator.addClient('disable', client.callback, client.writeFrame, ['srv_a|/repo']) hub.start() - const source = sources.sources[0]! - source.open() + const stream = fake.latest('srv_a') + stream.push(SERVER_CONNECTED) + await flushPromises() client.frames.length = 0 fake.setServer({ ...SERVER_A, enabled: false }) fake.emit({ type: 'upserted', serverId: 'srv_a' }) - expect(source.closed).toBe(true) + expect(stream.signal.aborted).toBe(true) expect(hub.getStatuses()).toEqual([ { serverId: 'srv_a', connected: false, version: null, error: null }, ]) - source.message(remoteEvent('session.idle', { sessionID: 'ses_1' }, '/repo')) + stream.push(remoteEvent('session.idle', { sessionID: 'ses_1' }, '/repo')) + await flushPromises() expect(client.frames).toHaveLength(0) }) - it('reconnects with a fresh stream when the server is updated', () => { + it('reconnects with a fresh client when the server is updated', () => { hub.start() - const first = sources.sources[0]! - first.open() + const first = fake.latest('srv_a') fake.setServer({ ...SERVER_A, baseUrl: 'https://moved.example.com' }) fake.emit({ type: 'upserted', serverId: 'srv_a' }) - expect(first.closed).toBe(true) - expect(sources.sources).toHaveLength(2) - expect(sources.sources[1]!.url).toBe('https://moved.example.com/api/event') + expect(first.signal.aborted).toBe(true) + expect(fake.transport('srv_a').subscriptions).toHaveLength(2) + expect(fake.clientCalls).toEqual(['srv_a', 'srv_a']) }) it('closes the stream when the server is deleted', () => { hub.start() - const source = sources.sources[0]! - source.open() + const stream = fake.latest('srv_a') fake.removeServer('srv_a') fake.emit({ type: 'deleted', serverId: 'srv_a' }) - expect(source.closed).toBe(true) + expect(stream.signal.aborted).toBe(true) expect(hub.getStatuses()).toEqual([]) }) - it('stops reconnecting and clears the replay hook after shutdown', () => { + it('stops reconnecting and clears the replay hook after shutdown', async () => { hub.start() - const source = sources.sources[0]! - source.open() + const stream = fake.latest('srv_a') + stream.push(SERVER_CONNECTED) + await flushPromises() hub.shutdown() - expect(source.closed).toBe(true) + expect(stream.signal.aborted).toBe(true) const client = createCapturingClient() sseAggregator.addClient('after-shutdown', client.callback, client.writeFrame, ['srv_a|/repo']) @@ -650,16 +733,13 @@ describe('RemoteServerEventHub reconnection', () => { describe('RemoteServerEventHub failure handling', () => { let hub: RemoteServerEventHub let fake: FakeRegistry - let sources: ReturnType beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) fake = createRegistry([SERVER_A]) - sources = createSourceFactory() hub = new RemoteServerEventHub(fake.registry, sseAggregator, { - eventSourceFactory: sources.factory, reconnectDelayMs: 1000, maxReconnectDelayMs: 4000, }) @@ -675,47 +755,48 @@ describe('RemoteServerEventHub failure handling', () => { vi.useFakeTimers() hub.start() - const first = sources.sources[0]! - first.fail(new Error('boom')) + fake.latest('srv_a').fail(new Error('boom')) + await flushPromises() expect(hub.getStatuses()[0]?.error).toBe('boom') await vi.advanceTimersByTimeAsync(1000) - sources.sources[1]!.fail('plain failure') + fake.latest('srv_a').fail('plain failure') + await flushPromises() expect(hub.getStatuses()[0]?.error).toBe('plain failure') await vi.advanceTimersByTimeAsync(2000) - sources.sources[2]!.fail({ code: 500 }) + fake.latest('srv_a').fail({ code: 500 }) + await flushPromises() expect(hub.getStatuses()[0]?.error).toBe('event stream closed (code=500)') await vi.advanceTimersByTimeAsync(4000) - sources.sources[3]!.fail({}) + fake.latest('srv_a').fail({}) + await flushPromises() expect(hub.getStatuses()[0]?.error).toBe('event stream disconnected') expect(hub.getStatuses()[0]?.error).not.toContain('Basic') expect(hub.getStatuses()[0]?.error).not.toContain('studio.example.com') }) - it('records a connection failure without credentials and without opening a stream', () => { + it('records a connection failure without credentials and without subscribing', () => { ;(fake.registry as unknown as { getConnection: (serverId: string) => never }).getConnection = () => { throw new Error('OpenCode server is disabled: srv_a') } hub.start() - expect(sources.sources).toHaveLength(0) + expect(fake.transport('srv_a').subscriptions).toHaveLength(0) expect(hub.getStatuses()).toEqual([ { serverId: 'srv_a', connected: false, version: null, error: 'OpenCode server is disabled: srv_a' }, ]) }) - it('retries with backoff when the event source cannot be created', async () => { + it('retries with backoff when the client cannot be created', async () => { vi.useFakeTimers() let attempts = 0 - const factory: RemoteEventSourceFactory = () => { + ;(fake.registry as unknown as { getClient: (serverId: string) => never }).getClient = () => { attempts += 1 throw new Error('bad event source') } - hub.shutdown() - hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: factory, reconnectDelayMs: 1000 }) hub.start() expect(attempts).toBe(1) @@ -735,24 +816,25 @@ describe('RemoteServerEventHub failure handling', () => { expect(hub.getStatuses()).toEqual([]) }) - it('forgets the stream when the server cannot be read on change', () => { + it('forgets the stream when the server cannot be read on change', async () => { hub.start() - const source = sources.sources[0]! - source.open() + const stream = fake.latest('srv_a') + stream.push(SERVER_CONNECTED) + await flushPromises() ;(fake.registry as unknown as { get: (serverId: string) => never }).get = () => { throw new Error('db down') } fake.emit({ type: 'upserted', serverId: 'srv_a' }) - expect(source.closed).toBe(true) + expect(stream.signal.aborted).toBe(true) }) it('keeps the version null when the version probe rejects', async () => { vi.mocked(fake.registry.test).mockRejectedValue(new Error('no info endpoint')) hub.start() - sources.sources[0]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() expect(hub.getStatuses()).toEqual([ @@ -764,18 +846,16 @@ describe('RemoteServerEventHub failure handling', () => { describe('RemoteServerEventHub pending action replay', () => { let hub: RemoteServerEventHub let fake: FakeRegistry - let sources: ReturnType beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) fake = createRegistry([SERVER_A], { permissions: { '/repo': [{ id: 'perm-1', sessionID: 'ses-1', action: 'shell', resources: ['ls'] }] }, forms: { '/repo': [{ id: 'form-1', sessionID: 'ses-1', title: 'Continue?', fields: [] }] }, }) - sources = createSourceFactory() - hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: sources.factory }) + hub = new RemoteServerEventHub(fake.registry, sseAggregator) }) afterEach(() => { @@ -783,13 +863,14 @@ describe('RemoteServerEventHub pending action replay', () => { sseAggregator.shutdown() }) - function trackSessionStatus(sessionID: string, directory: string, status: string): void { - sources.sources[0]!.message(remoteEvent('session.status', { sessionID, status: { type: status } }, directory)) + async function trackSessionStatus(sessionID: string, directory: string, status: string): Promise { + fake.latest('srv_a').push(remoteEvent('session.status', { sessionID, status: { type: status } }, directory)) + await flushPromises() } it('replays pending remote actions to the subscribing client only', async () => { hub.start() - trackSessionStatus('ses-1', '/repo', 'busy') + await trackSessionStatus('ses-1', '/repo', 'busy') const client = createCapturingClient() const other = createCapturingClient() sseAggregator.addClient('replay-a', client.callback, client.writeFrame, ['srv_a|/repo']) @@ -825,8 +906,8 @@ describe('RemoteServerEventHub pending action replay', () => { it('stops replaying for a linked directory once its session goes idle', async () => { hub.start() - trackSessionStatus('ses-1', '/repo', 'busy') - trackSessionStatus('ses-1', '/repo', 'idle') + await trackSessionStatus('ses-1', '/repo', 'busy') + await trackSessionStatus('ses-1', '/repo', 'idle') const client = createCapturingClient() sseAggregator.addClient('settled', client.callback, client.writeFrame, ['srv_a|/repo']) @@ -842,9 +923,9 @@ describe('RemoteServerEventHub pending action replay', () => { sessionDirectories: { 'ses-1': '/repo' }, permissions: { '/repo': [{ id: 'perm-1', sessionID: 'ses-1', action: 'shell', resources: ['ls'] }] }, }) - hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: sources.factory }) + hub = new RemoteServerEventHub(fake.registry, sseAggregator) hub.start() - sources.sources[0]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() const client = createCapturingClient() @@ -857,11 +938,11 @@ describe('RemoteServerEventHub pending action replay', () => { }) it('does not use the local pending action fetcher for remote keys', async () => { - const localApi = makeApi({ permissions: { '/repo': [{ id: 'local-perm' }] } }) - sseAggregator.setPendingActionsFetcher({ api: localApi }) + const localApi = makeApi({ permissions: { '/repo': [{ id: 'local-perm' }] } }, []) + sseAggregator.setOpenCodeClient({ api: localApi }) hub.start() - trackSessionStatus('ses-1', '/repo', 'busy') + await trackSessionStatus('ses-1', '/repo', 'busy') const client = createCapturingClient() sseAggregator.addClient('mixed', client.callback, client.writeFrame, ['/repo', 'srv_a|/repo']) @@ -875,7 +956,7 @@ describe('RemoteServerEventHub pending action replay', () => { it('replays remote actions when a client subscribes later', async () => { hub.start() - trackSessionStatus('ses-1', '/repo', 'busy') + await trackSessionStatus('ses-1', '/repo', 'busy') const client = createCapturingClient() sseAggregator.addClient('late', client.callback, client.writeFrame, []) @@ -894,7 +975,7 @@ describe('RemoteServerEventHub pending action replay', () => { vi.mocked(fake.api.form.list).mockResolvedValue({ location: { directory: '/repo' }, data: [] }) hub.start() - trackSessionStatus('ses-1', '/repo', 'busy') + await trackSessionStatus('ses-1', '/repo', 'busy') const client = createCapturingClient() sseAggregator.addClient('unsubscribed', client.callback, client.writeFrame, ['srv_a|/repo']) sseAggregator.removeDirectories('unsubscribed', ['srv_a|/repo']) @@ -909,13 +990,11 @@ describe('RemoteServerEventHub pending action replay', () => { describe('RemoteServerEventHub session status replay', () => { let hub: RemoteServerEventHub let fake: FakeRegistry - let sources: ReturnType beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) - sources = createSourceFactory() }) afterEach(() => { @@ -925,7 +1004,7 @@ describe('RemoteServerEventHub session status replay', () => { function createHub(fixture: ApiFixture = {}): void { fake = createRegistry([SERVER_A], fixture) - hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: sources.factory }) + hub = new RemoteServerEventHub(fake.registry, sseAggregator) } function statusFrames(client: CapturedClient) { @@ -934,10 +1013,10 @@ describe('RemoteServerEventHub session status replay', () => { async function trackSessionThenReconnect(sessionID: string, directory: string): Promise { hub.start() - const first = sources.sources[0]! - first.open() + fake.latest('srv_a').push(SERVER_CONNECTED) + await flushPromises() + fake.latest('srv_a').push(remoteEvent('session.created', { sessionID }, directory)) await flushPromises() - first.message(remoteEvent('session.created', { sessionID }, directory)) fake.emit({ type: 'upserted', serverId: 'srv_a' }) await flushPromises() } @@ -948,7 +1027,7 @@ describe('RemoteServerEventHub session status replay', () => { const client = createCapturingClient() sseAggregator.addClient('status-idle', client.callback, client.writeFrame, ['srv_a|/repo']) - sources.sources[1]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() const statuses = statusFrames(client) @@ -965,7 +1044,7 @@ describe('RemoteServerEventHub session status replay', () => { const client = createCapturingClient() sseAggregator.addClient('status-busy', client.callback, client.writeFrame, ['srv_a|/repo']) - sources.sources[1]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() const statuses = statusFrames(client) @@ -983,7 +1062,7 @@ describe('RemoteServerEventHub session status replay', () => { const client = createCapturingClient() sseAggregator.addClient('status-failure', client.callback, client.writeFrame, ['srv_a|/repo']) - sources.sources[1]!.open() + fake.latest('srv_a').push(SERVER_CONNECTED) await flushPromises() expect(fake.api.session.active).toHaveBeenCalledTimes(2) @@ -996,10 +1075,11 @@ describe('RemoteServerEventHub session status replay', () => { sseAggregator.addClient('status-stale', client.callback, client.writeFrame, ['srv_a|/repo']) hub.start() - const first = sources.sources[0]! - first.open() + const first = fake.latest('srv_a') + first.push(SERVER_CONNECTED) + await flushPromises() + first.push(remoteEvent('session.created', { sessionID: 'ses_1' }, '/repo')) await flushPromises() - first.message(remoteEvent('session.created', { sessionID: 'ses_1' }, '/repo')) let resolveActive: (value: Record) => void = () => {} vi.mocked(fake.api.session.active).mockReturnValueOnce(new Promise>((resolve) => { @@ -1007,12 +1087,13 @@ describe('RemoteServerEventHub session status replay', () => { }) as never) fake.emit({ type: 'upserted', serverId: 'srv_a' }) - const second = sources.sources[1]! - second.open() + const second = fake.latest('srv_a') + second.push(SERVER_CONNECTED) await flushPromises() expect(fake.api.session.active).toHaveBeenCalledTimes(2) - second.fail({ message: 'connection lost' }) + second.fail(new Error('connection lost')) + await flushPromises() resolveActive({}) await flushPromises() diff --git a/backend/test/services/sse-aggregator-remote.test.ts b/backend/test/services/sse-aggregator-remote.test.ts index 5e9fbbb61..3b94644af 100644 --- a/backend/test/services/sse-aggregator-remote.test.ts +++ b/backend/test/services/sse-aggregator-remote.test.ts @@ -14,8 +14,9 @@ vi.mock('../../src/utils/logger', () => ({ }, })) -import type { OpenCodeApi } from '@opencode-manager/shared/opencode' -import { sseAggregator, type PendingActionsFetcher } from '../../src/services/sse-aggregator' +import type { OpenCodeApi, V2Event } from '@opencode-manager/shared/opencode' +import { sseAggregator } from '../../src/services/sse-aggregator' +import type { OpenCodeClient } from '../../src/services/opencode/client' interface CapturedClient { events: Array<{ event: string; data: string }> @@ -40,7 +41,7 @@ function parseFrame(frame: string): { directory: string | null; serverId?: strin return JSON.parse(frame.replace(/^event: message\ndata: /, '').trim()) } -function makeFetcher(): PendingActionsFetcher { +function makeClient(): Pick { const api = { permission: { request: { @@ -66,12 +67,18 @@ async function flushReplay(): Promise { } } -const PAYLOAD = '{"id":"evt_1","created":1,"type":"session.idle","location":{"directory":"/repo"},"data":{"sessionID":"ses_1"}}' +const LOCAL_EVENT = { + id: 'evt_1', + created: 1, + type: 'session.idle', + location: { directory: '/repo' }, + data: { sessionID: 'ses_1' }, +} satisfies V2Event describe('SSEAggregator remote envelope delivery', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) }) @@ -87,9 +94,9 @@ describe('SSEAggregator remote envelope delivery', () => { sseAggregator.addClient('local-dir', local.callback, local.writeFrame, ['/repo']) sseAggregator.addClient('no-directories', unsubscribed.callback, unsubscribed.writeFrame, []) - sseAggregator.deliverRemoteEnvelope('srv_a', '/repo', PAYLOAD) + sseAggregator.deliverRemoteEnvelope('srv_a', '/repo', LOCAL_EVENT) - const expected = { directory: '/repo', serverId: 'srv_a', payload: JSON.parse(PAYLOAD) } + const expected = { directory: '/repo', serverId: 'srv_a', payload: LOCAL_EVENT } expect(subscribed.frames.map(parseFrame)).toEqual([expected]) expect(otherDirectory.frames.map(parseFrame)).toEqual([expected]) expect(otherServer.frames.map(parseFrame)).toEqual([expected]) @@ -107,9 +114,9 @@ describe('SSEAggregator remote envelope delivery', () => { sseAggregator.addClient('any-c', otherServer.callback, otherServer.writeFrame, ['srv_b|/repo']) sseAggregator.addClient('any-d', local.callback, local.writeFrame, ['/repo']) - sseAggregator.deliverRemoteEnvelope('srv_a', null, PAYLOAD) + sseAggregator.deliverRemoteEnvelope('srv_a', null, LOCAL_EVENT) - const expected = { directory: null, serverId: 'srv_a', payload: JSON.parse(PAYLOAD) } + const expected = { directory: null, serverId: 'srv_a', payload: LOCAL_EVENT } expect(first.frames.map(parseFrame)).toEqual([expected]) expect(second.frames.map(parseFrame)).toEqual([expected]) expect(otherServer.frames.map(parseFrame)).toEqual([expected]) @@ -121,7 +128,7 @@ describe('SSEAggregator remote envelope delivery', () => { sseAggregator.addClient('targeted', client.callback, client.writeFrame, ['srv_a|/repo']) sseAggregator.removeDirectories('targeted', ['srv_a|/repo']) - sseAggregator.deliverRemoteEnvelopeToClient('targeted', 'srv_a', '/repo', PAYLOAD) + sseAggregator.deliverRemoteEnvelopeToClient('targeted', 'srv_a', '/repo', LOCAL_EVENT) expect(client.frames).toHaveLength(0) }) @@ -130,10 +137,10 @@ describe('SSEAggregator remote envelope delivery', () => { const client = createCapturingClient() sseAggregator.addClient('targeted-ok', client.callback, client.writeFrame, ['srv_a|/repo']) - sseAggregator.deliverRemoteEnvelopeToClient('targeted-ok', 'srv_a', '/repo', PAYLOAD) + sseAggregator.deliverRemoteEnvelopeToClient('targeted-ok', 'srv_a', '/repo', LOCAL_EVENT) expect(client.frames.map(parseFrame)).toEqual([ - { directory: '/repo', serverId: 'srv_a', payload: JSON.parse(PAYLOAD) }, + { directory: '/repo', serverId: 'srv_a', payload: LOCAL_EVENT }, ]) }) @@ -159,16 +166,16 @@ describe('SSEAggregator remote envelope delivery', () => { const client = createCapturingClient() sseAggregator.addClient('local-envelope', client.callback, client.writeFrame, ['/repo']) - ;(sseAggregator as unknown as { handleUpstreamMessage(data: string): void }).handleUpstreamMessage(PAYLOAD) + ;(sseAggregator as unknown as { handleUpstreamEvent(event: V2Event): void }).handleUpstreamEvent(LOCAL_EVENT) - expect(client.frames).toEqual([`event: message\ndata: {"directory":"/repo","payload":${PAYLOAD}}\n\n`]) + expect(client.frames).toEqual([`event: message\ndata: {"directory":"/repo","payload":${JSON.stringify(LOCAL_EVENT)}}\n\n`]) expect(client.frames[0]).not.toContain('serverId') expect(parseFrame(client.frames[0]!).serverId).toBeUndefined() }) - it('routes remote subscriptions to the remote replay hook instead of the local fetcher', async () => { - const fetcher = makeFetcher() - sseAggregator.setPendingActionsFetcher(fetcher) + it('routes remote subscriptions to the remote replay hook instead of the local client', async () => { + const openCodeClient = makeClient() + sseAggregator.setOpenCodeClient(openCodeClient) const remoteReplay = vi.fn(async () => {}) sseAggregator.addClient('mixed', () => {}, () => {}, ['/repo', 'srv_a|/repo']) @@ -179,21 +186,21 @@ describe('SSEAggregator remote envelope delivery', () => { expect(remoteReplay).toHaveBeenCalledTimes(1) expect(remoteReplay).toHaveBeenCalledWith('mixed', 'srv_a', '/new') - expect(fetcher.api.permission.request.list).toHaveBeenCalledTimes(1) - expect(fetcher.api.permission.request.list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + expect(openCodeClient.api.permission.request.list).toHaveBeenCalledTimes(1) + expect(openCodeClient.api.permission.request.list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) }) - it('does not replay remote keys through the local fetcher on connect', async () => { - const fetcher = makeFetcher() - sseAggregator.setPendingActionsFetcher(fetcher) + it('does not replay remote keys through the local client on connect', async () => { + const openCodeClient = makeClient() + sseAggregator.setOpenCodeClient(openCodeClient) const remoteReplay = vi.fn() sseAggregator.setRemoteReplayHandler(remoteReplay) sseAggregator.addClient('remote-only', () => {}, () => {}, ['srv_a|/repo']) await flushReplay() - expect(fetcher.api.permission.request.list).not.toHaveBeenCalled() - expect(fetcher.api.form.list).not.toHaveBeenCalled() + expect(openCodeClient.api.permission.request.list).not.toHaveBeenCalled() + expect(openCodeClient.api.form.list).not.toHaveBeenCalled() expect(remoteReplay).toHaveBeenCalledWith('remote-only', 'srv_a', '/repo') }) @@ -215,8 +222,21 @@ describe('SSEAggregator remote envelope delivery', () => { sseAggregator.addClient('removed', client.callback, client.writeFrame, ['srv_a|/repo']) sseAggregator.removeClient('removed') - sseAggregator.deliverRemoteEnvelope('srv_a', null, PAYLOAD) + sseAggregator.deliverRemoteEnvelope('srv_a', null, LOCAL_EVENT) expect(client.frames).toHaveLength(0) }) + + it('does not serialize an envelope that no client receives', () => { + const stringify = vi.spyOn(JSON, 'stringify') + + try { + sseAggregator.deliverRemoteEnvelope('srv_a', '/repo', LOCAL_EVENT) + sseAggregator.deliverRemoteEnvelopeToClient('absent', 'srv_a', '/repo', LOCAL_EVENT) + + expect(stringify).not.toHaveBeenCalledWith(LOCAL_EVENT) + } finally { + stringify.mockRestore() + } + }) }) diff --git a/backend/test/services/sse-aggregator.test.ts b/backend/test/services/sse-aggregator.test.ts index 5ea5cd275..4fcb8e40c 100644 --- a/backend/test/services/sse-aggregator.test.ts +++ b/backend/test/services/sse-aggregator.test.ts @@ -4,6 +4,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ ENV: { OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, }, + getWorkspacePath: () => '/workspace', })) vi.mock('../../src/utils/logger', () => ({ @@ -14,9 +15,11 @@ vi.mock('../../src/utils/logger', () => ({ }, })) -import type { OpenCodeApi } from '@opencode-manager/shared/opencode' -import { sessionIDFromEvent } from '@opencode-manager/shared/opencode' -import { sseAggregator, type PendingActionsFetcher } from '../../src/services/sse-aggregator' +import type { OpenCodeApi, V2Event } from '@opencode-manager/shared/opencode' +import { buildOpenCodeBasicAuth, sessionIDFromEvent } from '@opencode-manager/shared/opencode' +import { sseAggregator } from '../../src/services/sse-aggregator' +import { FetchOpenCodeClient } from '../../src/services/opencode/client' +import { getOpenCodeUpstreamBaseUrl } from '../../src/services/opencode/upstream' interface CapturedEvent { event: string @@ -36,7 +39,7 @@ function createCapturingClient() { return { callback, writeFrame, events, frames } } -interface RawEventInput { +interface EventInput { id?: string created?: number type: string @@ -44,14 +47,14 @@ interface RawEventInput { data?: unknown } -function rawEvent(input: RawEventInput): string { - return JSON.stringify({ +function eventOf(input: EventInput): V2Event { + return { id: input.id ?? 'evt_1', created: input.created ?? 1, type: input.type, ...(input.location ? { location: input.location } : {}), data: input.data ?? {}, - }) + } as unknown as V2Event } function parseFrame(frame: string): { directory: string | null; payload: { id?: string; type: string; data: Record } } { @@ -94,12 +97,12 @@ function makeApi(fixture: ApiFixture = {}): OpenCodeApi { } as unknown as OpenCodeApi } -function makeFetcher(fixture: ApiFixture = {}): PendingActionsFetcher { +function makeClient(fixture: ApiFixture = {}): { api: OpenCodeApi } { return { api: makeApi(fixture) } } -function emitRawEvent(input: RawEventInput): void { - ;(sseAggregator as unknown as { handleUpstreamMessage(data: string): void }).handleUpstreamMessage(rawEvent(input)) +function emitEvent(input: EventInput): void { + ;(sseAggregator as unknown as { handleUpstreamEvent(event: V2Event): void }).handleUpstreamEvent(eventOf(input)) } async function flushReplay(): Promise { @@ -108,20 +111,20 @@ async function flushReplay(): Promise { } } -describe('SSEAggregator raw V2 event handling', () => { +describe('SSEAggregator V2 event handling', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) }) - it('marks a session active from a raw V2 session.status busy event and routes it only to subscribed clients', () => { + it('marks a session active from a V2 session.status busy event and routes it only to subscribed clients', () => { const clientA = createCapturingClient() const clientB = createCapturingClient() sseAggregator.addClient('active-a', clientA.callback, clientA.writeFrame, ['/r']) sseAggregator.addClient('active-b', clientB.callback, clientB.writeFrame, ['/other']) - emitRawEvent({ + emitEvent({ id: 'evt_1', type: 'session.status', location: { directory: '/r' }, @@ -149,7 +152,7 @@ describe('SSEAggregator raw V2 event handling', () => { sseAggregator.addClient('global-a', subscribed.callback, subscribed.writeFrame, ['/r']) sseAggregator.addClient('global-b', unsubscribed.callback, unsubscribed.writeFrame, []) - emitRawEvent({ id: 'evt_global', type: 'server.connected', data: {} }) + emitEvent({ id: 'evt_global', type: 'server.connected', data: {} }) const expected = { directory: null, @@ -160,56 +163,61 @@ describe('SSEAggregator raw V2 event handling', () => { expect(sseAggregator.getActiveSessions()).toEqual({}) }) - it('embeds the raw upstream payload in the client envelope without re-serializing it', () => { - const client = createCapturingClient() - sseAggregator.addClient('raw-a', client.callback, client.writeFrame, ['/r']) - const raw = '{"type":"session.idle","id":"evt_raw","created":7,"location":{"directory":"/r"},"data":{"sessionID":"ses_1","extra":{"b":2,"a":1}}}' - - ;(sseAggregator as unknown as { handleUpstreamMessage(data: string): void }).handleUpstreamMessage(raw) - - expect(client.frames).toEqual([`event: message\ndata: {"directory":"/r","payload":${raw}}\n\n`]) - expect(parseFrame(client.frames[0]!)).toEqual({ directory: '/r', payload: JSON.parse(raw) }) - }) - - it('re-serializes a multi-line upstream payload so the client frame stays a single data line', () => { + it('delivers the parsed event payload in a single data line', () => { const client = createCapturingClient() - sseAggregator.addClient('raw-b', client.callback, client.writeFrame, ['/r']) - const event = { type: 'session.idle', id: 'evt_ml', created: 3, location: { directory: '/r' }, data: { sessionID: 'ses_1' } } + sseAggregator.addClient('payload-a', client.callback, client.writeFrame, ['/r']) + const event = eventOf({ + id: 'evt_raw', + created: 7, + type: 'session.idle', + location: { directory: '/r' }, + data: { sessionID: 'ses_1', extra: { b: 2, a: 1 } }, + }) - ;(sseAggregator as unknown as { handleUpstreamMessage(data: string): void }).handleUpstreamMessage(JSON.stringify(event, null, 2)) + ;(sseAggregator as unknown as { handleUpstreamEvent(event: V2Event): void }).handleUpstreamEvent(event) - expect(client.frames).toHaveLength(1) - expect(client.frames[0]!.split('\n').filter(line => line.startsWith('data: '))).toHaveLength(1) + expect(client.frames).toEqual([`event: message\ndata: {"directory":"/r","payload":${JSON.stringify(event)}}\n\n`]) expect(parseFrame(client.frames[0]!)).toEqual({ directory: '/r', payload: event }) + expect(client.frames[0]!.split('\n').filter(line => line.startsWith('data: '))).toHaveLength(1) }) it('marks a session idle on session.idle and on terminal execution events', () => { - emitRawEvent({ type: 'session.status', location: { directory: '/r' }, data: { sessionID: 'ses_1', status: { type: 'busy' } } }) - emitRawEvent({ type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_2' } }) + emitEvent({ type: 'session.status', location: { directory: '/r' }, data: { sessionID: 'ses_1', status: { type: 'busy' } } }) + emitEvent({ type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_2' } }) expect(sseAggregator.getActiveSessions()).toEqual({ '/r': ['ses_1', 'ses_2'] }) - emitRawEvent({ type: 'session.execution.succeeded', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) + emitEvent({ type: 'session.execution.succeeded', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) expect(sseAggregator.getActiveSessions()).toEqual({ '/r': ['ses_2'] }) - emitRawEvent({ type: 'session.execution.failed', location: { directory: '/r' }, data: { sessionID: 'ses_2', error: { type: 'unknown', message: 'boom' } } }) - emitRawEvent({ type: 'session.status', location: { directory: '/r' }, data: { sessionID: 'ses_3', status: { type: 'retry', attempt: 1, message: 'retrying', next: 1 } } }) + emitEvent({ type: 'session.execution.failed', location: { directory: '/r' }, data: { sessionID: 'ses_2', error: { type: 'unknown', message: 'boom' } } }) + emitEvent({ type: 'session.status', location: { directory: '/r' }, data: { sessionID: 'ses_3', status: { type: 'retry', attempt: 1, message: 'retrying', next: 1 } } }) expect(sseAggregator.getActiveSessions()).toEqual({ '/r': ['ses_3'] }) - emitRawEvent({ type: 'session.idle', location: { directory: '/r' }, data: { sessionID: 'ses_3' } }) + emitEvent({ type: 'session.idle', location: { directory: '/r' }, data: { sessionID: 'ses_3' } }) expect(sseAggregator.getActiveSessions()).toEqual({}) }) it('marks a session idle on session.execution.interrupted', () => { - emitRawEvent({ type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) - emitRawEvent({ type: 'session.execution.interrupted', location: { directory: '/r' }, data: { sessionID: 'ses_1', reason: 'shutdown' } }) + emitEvent({ type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) + emitEvent({ type: 'session.execution.interrupted', location: { directory: '/r' }, data: { sessionID: 'ses_1', reason: 'shutdown' } }) + + expect(sseAggregator.getActiveSessions()).toEqual({}) + }) + + it('clears a busy session from the active set when it is deleted without going idle', () => { + emitEvent({ type: 'session.status', location: { directory: '/r' }, data: { sessionID: 'ses_deleted', status: { type: 'busy' } } }) + + expect(sseAggregator.getActiveSessions()).toEqual({ '/r': ['ses_deleted'] }) + + emitEvent({ type: 'session.deleted', location: { directory: '/r' }, data: { sessionID: 'ses_deleted' } }) expect(sseAggregator.getActiveSessions()).toEqual({}) }) it('records subagent sessions from session.created parentID and forgets deleted ones', () => { - emitRawEvent({ + emitEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_sub', parentID: 'ses_root', projectID: 'p', location: { directory: '/r' }, slug: 'sub', version: '2.0.15' }, @@ -217,35 +225,21 @@ describe('SSEAggregator raw V2 event handling', () => { expect(sseAggregator.isSubagentSession('ses_sub')).toBe(true) - emitRawEvent({ type: 'session.deleted', location: { directory: '/r' }, data: { sessionID: 'ses_sub' } }) + emitEvent({ type: 'session.deleted', location: { directory: '/r' }, data: { sessionID: 'ses_sub' } }) expect(sseAggregator.isSubagentSession('ses_sub')).toBe(false) }) - - it('ignores malformed upstream payloads', () => { - const client = createCapturingClient() - sseAggregator.addClient('malformed-a', client.callback, client.writeFrame, ['/r']) - - ;(sseAggregator as unknown as { handleUpstreamMessage(data: string): void }).handleUpstreamMessage('not-json') - ;(sseAggregator as unknown as { handleUpstreamMessage(data: string): void }).handleUpstreamMessage(rawEvent({ - type: 'session.status', - location: { directory: '/r' }, - data: null, - })) - - expect(client.frames).toHaveLength(0) - }) }) describe('SSEAggregator pending replay on connect', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) }) it('replays pending permissions and forms from the V2 api per subscribed directory', async () => { - sseAggregator.setPendingActionsFetcher(makeFetcher({ + sseAggregator.setOpenCodeClient(makeClient({ permissions: { '/repo/a': [ { id: 'perm-1', sessionID: 'ses-a', action: 'shell', resources: ['rm -rf node_modules'] }, @@ -301,7 +295,7 @@ describe('SSEAggregator pending replay on connect', () => { }) it('does not replay to other clients', async () => { - sseAggregator.setPendingActionsFetcher(makeFetcher({ + sseAggregator.setOpenCodeClient(makeClient({ permissions: { '/repo/a': [{ id: 'perm-1', sessionID: 'ses-a', action: 'shell', resources: ['ls'] }] }, })) @@ -318,7 +312,7 @@ describe('SSEAggregator pending replay on connect', () => { }) it('replays only newly added directories on addDirectories', async () => { - sseAggregator.setPendingActionsFetcher(makeFetcher({ + sseAggregator.setOpenCodeClient(makeClient({ permissions: { '/repo/a': [{ id: 'perm-1', sessionID: 'ses-a', action: 'shell', resources: ['ls'] }], '/repo/b': [{ id: 'perm-2', sessionID: 'ses-b', action: 'shell', resources: ['ls'] }], @@ -353,7 +347,7 @@ describe('SSEAggregator pending replay on connect', () => { if (input?.location?.directory === '/repo/broken') throw new Error('upstream down') return { location: { directory: input?.location?.directory ?? '' }, data: [{ id: 'perm-ok', sessionID: 'ses-ok', action: 'shell', resources: ['ls'] }] } }) - sseAggregator.setPendingActionsFetcher({ api }) + sseAggregator.setOpenCodeClient({ api }) const { callback, writeFrame, events } = createCapturingClient() sseAggregator.addClient('client-4', callback, writeFrame, ['/repo/broken', '/repo/ok']) @@ -372,7 +366,7 @@ describe('SSEAggregator pending replay on connect', () => { vi.mocked(list).mockImplementation(() => new Promise((resolve) => { resolvePermissions = resolve as (value: { location: { directory: string }; data: unknown[] }) => void })) - sseAggregator.setPendingActionsFetcher({ api }) + sseAggregator.setOpenCodeClient({ api }) const { callback, writeFrame, events } = createCapturingClient() sseAggregator.addClient('client-5', callback, writeFrame, ['/repo/a']) @@ -389,7 +383,7 @@ describe('SSEAggregator pending replay on connect', () => { describe('SSEAggregator session status replay on upstream reconnect', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) }) @@ -397,9 +391,9 @@ describe('SSEAggregator session status replay on upstream reconnect', () => { const client = createCapturingClient() sseAggregator.addClient('status-1', client.callback, client.writeFrame, ['/repo/a']) - emitRawEvent({ type: 'session.status', location: { directory: '/repo/a' }, data: { sessionID: 'ses-1', status: { type: 'busy' } } }) + emitEvent({ type: 'session.status', location: { directory: '/repo/a' }, data: { sessionID: 'ses-1', status: { type: 'busy' } } }) - sseAggregator.setPendingActionsFetcher(makeFetcher({ active: { 'ses-1': { type: 'running' } } })) + sseAggregator.setOpenCodeClient(makeClient({ active: { 'ses-1': { type: 'running' } } })) await (sseAggregator as unknown as { replaySessionStatusesForTrackedDirectories(): Promise }).replaySessionStatusesForTrackedDirectories() await flushReplay() @@ -413,9 +407,9 @@ describe('SSEAggregator session status replay on upstream reconnect', () => { const client = createCapturingClient() sseAggregator.addClient('status-2', client.callback, client.writeFrame, ['/repo/a']) - emitRawEvent({ type: 'session.status', location: { directory: '/repo/a' }, data: { sessionID: 'ses-9', status: { type: 'busy' } } }) + emitEvent({ type: 'session.status', location: { directory: '/repo/a' }, data: { sessionID: 'ses-9', status: { type: 'busy' } } }) - sseAggregator.setPendingActionsFetcher(makeFetcher({ active: {} })) + sseAggregator.setOpenCodeClient(makeClient({ active: {} })) await (sseAggregator as unknown as { replaySessionStatusesForTrackedDirectories(): Promise }).replaySessionStatusesForTrackedDirectories() await flushReplay() @@ -431,7 +425,7 @@ describe('SSEAggregator session status replay on upstream reconnect', () => { active: { 'ses-new': { type: 'running' } }, sessions: { 'ses-new': { directory: '/repo/new' } }, }) - sseAggregator.setPendingActionsFetcher({ api }) + sseAggregator.setOpenCodeClient({ api }) const client = createCapturingClient() sseAggregator.addClient('status-3', client.callback, client.writeFrame, ['/repo/new']) @@ -453,7 +447,7 @@ describe('SSEAggregator session status replay on upstream reconnect', () => { const sessionID = (input as { sessionID: string }).sessionID pending.push(() => resolve({ location: { directory: `/repo/${sessionID}` } } as unknown as Awaited>)) })) - sseAggregator.setPendingActionsFetcher({ api }) + sseAggregator.setOpenCodeClient({ api }) const client = createCapturingClient() sseAggregator.addClient('status-5', client.callback, client.writeFrame, ['/repo/ses-x', '/repo/ses-y']) @@ -469,10 +463,10 @@ describe('SSEAggregator session status replay on upstream reconnect', () => { }) it('does not replay pending actions to already connected clients on upstream reconnect', async () => { - const fetcher = makeFetcher({ + const fetcher = makeClient({ permissions: { '/repo/a': [{ id: 'perm-1', sessionID: 'ses-a', action: 'shell', resources: ['ls'] }] }, }) - sseAggregator.setPendingActionsFetcher(fetcher) + sseAggregator.setOpenCodeClient(fetcher) const client = createCapturingClient() sseAggregator.addClient('status-6', client.callback, client.writeFrame, ['/repo/a']) await flushReplay() @@ -503,12 +497,12 @@ describe('SSEAggregator session status replay on upstream reconnect', () => { describe('SSEAggregator scheduled session replay without a connected client', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) }) it('replays a scheduled directory that no browser client is subscribed to', async () => { - sseAggregator.setPendingActionsFetcher(makeFetcher({ + sseAggregator.setOpenCodeClient(makeClient({ active: { 'ses-sched': { type: 'running' } }, })) sseAggregator.setScheduledSessionsResolver(() => [ @@ -535,7 +529,7 @@ describe('SSEAggregator scheduled session replay without a connected client', () }) it('emits idle for a scheduled session that finished while the stream was down and was never marked active', async () => { - sseAggregator.setPendingActionsFetcher(makeFetcher({ active: {} })) + sseAggregator.setOpenCodeClient(makeClient({ active: {} })) sseAggregator.setScheduledSessionsResolver(() => [ { sessionID: 'ses-finished', directory: '/worktrees/run-2' }, ]) @@ -557,8 +551,34 @@ describe('SSEAggregator scheduled session replay without a connected client', () describe('SSEAggregator upstream resynchronization signal', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) + sseAggregator.setResyncGate(() => true) + }) + + it('holds the resync while the gate is closed and broadcasts it once when released', () => { + let gateOpen = false + sseAggregator.setResyncGate(() => gateOpen) + const client = createCapturingClient() + sseAggregator.addClient('resync-gate', client.callback, client.writeFrame, ['/repo/a']) + const resyncCount = () => client.events.filter(event => event.event === 'resync').length + + ;(sseAggregator as unknown as { handleUpstreamOpen(wasConnectedBefore: boolean): void }).handleUpstreamOpen(true) + expect(resyncCount()).toBe(0) + + gateOpen = true + sseAggregator.releasePendingResync() + sseAggregator.releasePendingResync() + expect(resyncCount()).toBe(1) + }) + + it('does not broadcast on release when no resync is held', () => { + const client = createCapturingClient() + sseAggregator.addClient('resync-none', client.callback, client.writeFrame, ['/repo/a']) + + sseAggregator.releasePendingResync() + + expect(client.events.filter(event => event.event === 'resync')).toHaveLength(0) }) it('broadcasts a resync control event on the first and every later upstream connect', () => { @@ -616,7 +636,7 @@ describe('SSEAggregator upstream resynchronization signal', () => { describe('SSEAggregator directory-indexed broadcast', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) }) @@ -626,7 +646,7 @@ describe('SSEAggregator directory-indexed broadcast', () => { sseAggregator.addClient('index-a', clientA.callback, clientA.writeFrame, ['/a']) sseAggregator.addClient('index-b', clientB.callback, clientB.writeFrame, ['/b']) - emitRawEvent({ type: 'session.idle', location: { directory: '/a' }, data: { sessionID: 'ses-1' } }) + emitEvent({ type: 'session.idle', location: { directory: '/a' }, data: { sessionID: 'ses-1' } }) expect(clientA.frames).toHaveLength(1) expect(clientB.frames).toHaveLength(0) @@ -636,7 +656,7 @@ describe('SSEAggregator directory-indexed broadcast', () => { const clientA = createCapturingClient() sseAggregator.addClient('index-c', clientA.callback, clientA.writeFrame, ['/a']) - emitRawEvent({ type: 'session.idle', location: { directory: '/z' }, data: { sessionID: 'ses-1' } }) + emitEvent({ type: 'session.idle', location: { directory: '/z' }, data: { sessionID: 'ses-1' } }) expect(clientA.frames).toHaveLength(0) }) @@ -646,7 +666,7 @@ describe('SSEAggregator directory-indexed broadcast', () => { sseAggregator.addClient('index-d', clientA.callback, clientA.writeFrame, ['/a']) sseAggregator.removeClient('index-d') - emitRawEvent({ type: 'session.idle', location: { directory: '/a' }, data: { sessionID: 'ses-1' } }) + emitEvent({ type: 'session.idle', location: { directory: '/a' }, data: { sessionID: 'ses-1' } }) expect(clientA.frames).toHaveLength(0) }) @@ -656,7 +676,7 @@ describe('SSEAggregator directory-indexed broadcast', () => { sseAggregator.addClient('index-e', clientA.callback, clientA.writeFrame, []) sseAggregator.addDirectories('index-e', ['/a']) - emitRawEvent({ type: 'session.idle', location: { directory: '/a' }, data: { sessionID: 'ses-1' } }) + emitEvent({ type: 'session.idle', location: { directory: '/a' }, data: { sessionID: 'ses-1' } }) expect(clientA.frames).toHaveLength(1) }) @@ -668,11 +688,11 @@ describe('SSEAggregator directory-indexed broadcast', () => { sseAggregator.addClient('index-f', clientA.callback, clientA.writeFrame, ['/a']) sseAggregator.addClient('index-f', clientB.callback, clientB.writeFrame, ['/b']) - emitRawEvent({ type: 'session.idle', location: { directory: '/a' }, data: { sessionID: 'ses-1' } }) + emitEvent({ type: 'session.idle', location: { directory: '/a' }, data: { sessionID: 'ses-1' } }) expect(clientA.frames).toHaveLength(0) expect(clientB.frames).toHaveLength(0) - emitRawEvent({ type: 'session.idle', location: { directory: '/b' }, data: { sessionID: 'ses-1' } }) + emitEvent({ type: 'session.idle', location: { directory: '/b' }, data: { sessionID: 'ses-1' } }) expect(clientB.frames).toHaveLength(1) }) }) @@ -680,7 +700,7 @@ describe('SSEAggregator directory-indexed broadcast', () => { describe('SSEAggregator directory-less session event routing', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) }) @@ -695,10 +715,10 @@ describe('SSEAggregator directory-less session event routing', () => { seen.push({ directory, type: event.type, sessionID: sessionIDFromEvent(event) ?? '' }) }) - emitRawEvent({ type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) + emitEvent({ type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) expect(sseAggregator.getActiveSessions()).toEqual({ '/r': ['ses_1'] }) - emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }) + emitEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }) expect(seen).toEqual([ { directory: '/r', type: 'session.execution.started', sessionID: 'ses_1' }, @@ -731,7 +751,7 @@ describe('SSEAggregator directory-less session event routing', () => { const seen: Array<{ directory: string | null; type: string }> = [] sseAggregator.onEvent((directory, event) => { seen.push({ directory, type: event.type }) }) - emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_unknown' } }) + emitEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_unknown' } }) expect(seen).toEqual([{ directory: null, type: 'session.execution.succeeded' }]) expect(sseAggregator.getActiveSessions()).toEqual({}) @@ -752,7 +772,7 @@ describe('SSEAggregator directory-less session event routing', () => { seen.push({ directory, type: event.type, sessionID: sessionIDFromEvent(event) ?? '' }) }) - emitRawEvent({ type: 'session.deleted', data: { sessionID: 'ses_unknown' } }) + emitEvent({ type: 'session.deleted', data: { sessionID: 'ses_unknown' } }) expect(seen).toEqual([{ directory: null, type: 'session.deleted', sessionID: 'ses_unknown' }]) expect(client.frames.map(parseFrame)).toEqual([ @@ -770,7 +790,7 @@ describe('SSEAggregator directory-less session event routing', () => { const seen: Array<{ directory: string | null; type: string }> = [] sseAggregator.onEvent((directory, event) => { seen.push({ directory, type: event.type }) }) - emitRawEvent({ type: 'session.deleted', location: { directory: '/r' }, data: { sessionID: 'ses_located' } }) + emitEvent({ type: 'session.deleted', location: { directory: '/r' }, data: { sessionID: 'ses_located' } }) expect(seen).toEqual([{ directory: '/r', type: 'session.deleted' }]) expect(client.frames.map(parseFrame)).toEqual([ @@ -788,11 +808,11 @@ describe('SSEAggregator directory-less session event routing', () => { const seen: Array = [] sseAggregator.onEvent((directory) => { seen.push(directory) }) - emitRawEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_del' } }) - emitRawEvent({ type: 'session.deleted', data: { sessionID: 'ses_del' } }) + emitEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_del' } }) + emitEvent({ type: 'session.deleted', data: { sessionID: 'ses_del' } }) seen.length = 0 - emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_del' } }) + emitEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_del' } }) expect(seen).toEqual([null]) expect(sseAggregator.getActiveSessions()).toEqual({}) @@ -813,10 +833,10 @@ describe('SSEAggregator directory-less session event routing', () => { seen.push({ directory, type: event.type, sessionID: sessionIDFromEvent(event) ?? '' }) }) - emitRawEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_source' } }) + emitEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_source' } }) seen.length = 0 - emitRawEvent({ type: 'session.forked', data: { sessionID: 'ses_fork', parentID: 'ses_source', boundary: { messageID: 'msg_1' } } }) + emitEvent({ type: 'session.forked', data: { sessionID: 'ses_fork', parentID: 'ses_source', boundary: { messageID: 'msg_1' } } }) expect(seen).toEqual([{ directory: '/r', type: 'session.forked', sessionID: 'ses_fork' }]) @@ -830,7 +850,7 @@ describe('SSEAggregator directory-less session event routing', () => { expect(global.frames.map(parseFrame).at(-1)).toEqual({ directory: null, payload: forkPayload }) seen.length = 0 - emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_fork' } }) + emitEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_fork' } }) expect(seen).toEqual([{ directory: '/r', type: 'session.execution.succeeded', sessionID: 'ses_fork' }]) }) @@ -839,7 +859,7 @@ describe('SSEAggregator directory-less session event routing', () => { describe('SSEAggregator server-aware event feed', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) }) @@ -849,8 +869,8 @@ describe('SSEAggregator server-aware event feed', () => { seen.push({ serverId, directory, type: event.type }) }) - emitRawEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) - emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }) + emitEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) + emitEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }) expect(seen).toEqual([ { serverId: 'local', directory: '/r', type: 'session.created' }, @@ -929,7 +949,7 @@ describe('SSEAggregator server-aware event feed', () => { describe('SSEAggregator location-scoped catalog event broadcast', () => { beforeEach(() => { sseAggregator.shutdown() - sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setOpenCodeClient(null) sseAggregator.setScheduledSessionsResolver(() => []) }) @@ -946,7 +966,7 @@ describe('SSEAggregator location-scoped catalog event broadcast', () => { seen.push({ directory, type: event.type }) }) - emitRawEvent({ id: 'evt_catalog', type, location: { directory: '/repo-c' }, data: {} }) + emitEvent({ id: 'evt_catalog', type, location: { directory: '/repo-c' }, data: {} }) const expected = { directory: null, @@ -956,7 +976,7 @@ describe('SSEAggregator location-scoped catalog event broadcast', () => { expect(unsubscribed.frames.map(parseFrame)).toEqual([expected]) expect(seen).toEqual([{ directory: '/repo-c', type }]) - emitRawEvent({ + emitEvent({ id: 'evt_status', type: 'session.status', location: { directory: '/repo-c' }, @@ -968,3 +988,173 @@ describe('SSEAggregator location-scoped catalog event broadcast', () => { }, ) }) + +interface ControlledEventStream { + iterable: AsyncIterable + signal: AbortSignal + push: (event: V2Event) => void + end: () => void +} + +function createControlledEventStream(): ControlledEventStream { + const queue: V2Event[] = [] + let finished = false + let signal: AbortSignal + let notify: (() => void) | null = null + + const wake = (): void => { + const current = notify + notify = null + current?.() + } + + const iterable: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + async next() { + for (;;) { + if (queue.length > 0) return { value: queue.shift()!, done: false } + if (finished) return { value: undefined, done: true } + await new Promise((resolve) => { + notify = resolve + }) + } + }, + }), + } + + return { + iterable, + get signal() { + return signal + }, + set signal(next: AbortSignal) { + signal = next + next.addEventListener('abort', wake, { once: true }) + }, + push: (event) => { + queue.push(event) + wake() + }, + end: () => { + finished = true + wake() + }, + } as ControlledEventStream +} + +function makeStreamingApi(active: Record = {}) { + const subscriptions: ControlledEventStream[] = [] + const api = { + ...makeApi({ active }), + event: { + subscribe: (options: { signal?: AbortSignal }) => { + const stream = createControlledEventStream() + stream.signal = options.signal! + subscriptions.push(stream) + return stream.iterable + }, + }, + } as unknown as OpenCodeApi + return { api, subscriptions } +} + +describe('SSEAggregator upstream transport', () => { + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setOpenCodeClient(null) + sseAggregator.setScheduledSessionsResolver(() => []) + }) + + it('streams through the OpenCode client host override and resolved password', async () => { + let streamController: ReadableStreamDefaultController | null = null + const fetchFn = vi.fn(async () => new Response( + new ReadableStream({ + start(controller) { + streamController = controller + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + )) + const client = new FetchOpenCodeClient({ + baseUrl: getOpenCodeUpstreamBaseUrl('10.20.30.40'), + basicAuth: null, + passwordResolver: () => 'resolved-secret', + fetchFn: fetchFn as unknown as typeof fetch, + }) + + const captured = createCapturingClient() + sseAggregator.addClient('transport-a', captured.callback, captured.writeFrame, ['/repo']) + sseAggregator.setOpenCodeClient(client) + sseAggregator.start() + await flushReplay() + + expect(fetchFn).toHaveBeenCalledTimes(1) + const [url, init] = fetchFn.mock.calls[0] as unknown as [URL, RequestInit] + expect(String(url)).toBe('http://10.20.30.40:5551/api/event') + expect(new Headers(init.headers).get('Authorization')).toBe(buildOpenCodeBasicAuth('resolved-secret')) + + const encoder = new TextEncoder() + streamController!.enqueue(encoder.encode(`data: ${JSON.stringify(eventOf({ type: 'server.connected', data: {} }))}\n\n`)) + const busy = eventOf({ + type: 'session.status', + location: { directory: '/repo' }, + data: { sessionID: 'ses-1', status: { type: 'busy' } }, + }) + streamController!.enqueue(encoder.encode(`data: ${JSON.stringify(busy)}\n\n`)) + await flushReplay() + + expect(captured.events.filter(event => event.event === 'resync')).toHaveLength(1) + expect(captured.frames.map(parseFrame).filter(frame => frame.payload.type === 'session.status')).toEqual([ + { directory: '/repo', payload: busy }, + ]) + + streamController!.close() + sseAggregator.shutdown() + }) +}) + +describe('SSEAggregator upstream reconnection', () => { + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setOpenCodeClient(null) + sseAggregator.setScheduledSessionsResolver(() => []) + }) + + it('reconnects after the stream ends, broadcasting resync and replaying tracked statuses', async () => { + vi.useFakeTimers() + try { + const { api, subscriptions } = makeStreamingApi({ 'ses-1': { type: 'running' } }) + const client = createCapturingClient() + sseAggregator.addClient('reconnect-a', client.callback, client.writeFrame, ['/repo/a']) + sseAggregator.setOpenCodeClient({ api }) + sseAggregator.start() + + expect(subscriptions).toHaveLength(1) + subscriptions[0]!.push(eventOf({ type: 'server.connected', data: {} })) + subscriptions[0]!.push(eventOf({ + type: 'session.status', + location: { directory: '/repo/a' }, + data: { sessionID: 'ses-1', status: { type: 'busy' } }, + })) + await flushReplay() + + expect(client.events.filter(event => event.event === 'resync')).toHaveLength(1) + expect(sseAggregator.getActiveSessions()).toEqual({ '/repo/a': ['ses-1'] }) + + subscriptions[0]!.end() + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(1000) + + expect(subscriptions).toHaveLength(2) + subscriptions[1]!.push(eventOf({ type: 'server.connected', data: {} })) + await flushReplay() + + expect(client.events.filter(event => event.event === 'resync')).toHaveLength(2) + expect(api.session.active).toHaveBeenCalledTimes(1) + const statuses = client.frames.map(parseFrame).filter(frame => frame.payload.type === 'session.status') + expect(statuses.at(-1)?.payload.data).toEqual({ sessionID: 'ses-1', status: { type: 'busy' } }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/backend/test/shared/opencode-mcp.test.ts b/backend/test/shared/opencode-mcp.test.ts index b6ad03a30..b3f3138a9 100644 --- a/backend/test/shared/opencode-mcp.test.ts +++ b/backend/test/shared/opencode-mcp.test.ts @@ -49,37 +49,28 @@ describe('mcpServersFromConfig', () => { }) describe('mcpServerViewsFromConfig', () => { - it('normalizes native and legacy servers, reporting each shape and enabled state', () => { + it('normalizes native servers and their enabled state', () => { expect( mcpServerViewsFromConfig({ timeout: { catalog: 5000 }, - legacy: { type: 'local', command: ['npx', 'legacy'], enabled: false }, + flat: { type: 'local', command: ['npx', 'flat'], enabled: false }, servers: { remote: { type: 'remote', url: 'https://native.example.com', disabled: true }, local: { type: 'local', command: ['npx', 'native'] }, }, }), ).toEqual([ - { - name: 'legacy', - type: 'local', - command: ['npx', 'legacy'], - enabled: false, - shape: 'legacy', - }, { name: 'local', type: 'local', command: ['npx', 'native'], enabled: true, - shape: 'servers', }, { name: 'remote', type: 'remote', url: 'https://native.example.com', enabled: false, - shape: 'servers', }, ]) }) diff --git a/backend/test/utils/proxy-forward.test.ts b/backend/test/utils/proxy-forward.test.ts new file mode 100644 index 000000000..50a81838d --- /dev/null +++ b/backend/test/utils/proxy-forward.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from 'vitest' +import { + forwardProxyRequest, + ProxyHeadersTimeoutError, + type ForwardProxyRequestInput, +} from '../../src/utils/proxy-forward' + +function forward(overrides: Partial = {}): Promise { + return forwardProxyRequest({ + url: 'http://upstream.example/api/event', + method: 'GET', + headers: { 'x-test': '1' }, + body: null, + buildResponseHeaders: (headers) => new Headers(headers), + ...overrides, + }) +} + +function deferred() { + let resolve!: () => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +async function readWithin( + reader: ReadableStreamDefaultReader, + timeoutMs = 500, +) { + let timer: ReturnType | undefined + try { + return await Promise.race([ + reader.read(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('upstream chunk was buffered instead of streamed')), timeoutMs) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +describe('forwardProxyRequest', () => { + it('streams the upstream body instead of buffering it', async () => { + const encoder = new TextEncoder() + const secondChunk = deferred() + let secondChunkEnqueued = false + const upstream = new Response(new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('first')) + await secondChunk.promise + secondChunkEnqueued = true + controller.enqueue(encoder.encode('second')) + controller.close() + }, + })) + const fetchFn = vi.fn().mockResolvedValue(upstream) + + const response = await forward({ fetchFn: fetchFn as unknown as typeof fetch }) + const reader = response.body?.getReader() + expect(reader).toBeDefined() + + const decoder = new TextDecoder() + const first = await readWithin(reader as ReadableStreamDefaultReader) + expect(decoder.decode(first.value)).toBe('first') + expect(secondChunkEnqueued).toBe(false) + + secondChunk.resolve() + const readerRef = reader as ReadableStreamDefaultReader + expect(decoder.decode((await readerRef.read()).value)).toBe('second') + expect((await readerRef.read()).done).toBe(true) + }) + + it.each([204, 304])('returns a null body for upstream status %i', async (status) => { + const fetchFn = vi.fn().mockResolvedValue(new Response(null, { status })) + + const response = await forward({ fetchFn: fetchFn as unknown as typeof fetch }) + + expect(response.status).toBe(status) + expect(response.body).toBeNull() + }) + + it('aborts a body-less request when the upstream sends no headers in time', async () => { + let receivedSignal: AbortSignal | undefined + const fetchFn = vi.fn((_url: string | URL, init: RequestInit) => new Promise((_resolve, reject) => { + receivedSignal = init.signal ?? undefined + init.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + })) + + await expect( + forward({ fetchFn: fetchFn as unknown as typeof fetch, headersTimeoutMs: 10 }), + ).rejects.toBeInstanceOf(ProxyHeadersTimeoutError) + expect(receivedSignal?.aborted).toBe(true) + }) + + it('does not apply the headers timeout to a request with a body', async () => { + const client = new AbortController() + let receivedSignal: AbortSignal | undefined + const fetchFn = vi.fn((_url: string | URL, init: RequestInit) => new Promise((resolve, reject) => { + receivedSignal = init.signal ?? undefined + init.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + setTimeout(() => resolve(new Response('ok')), 40) + })) + + const response = await forward({ + method: 'POST', + body: '{"title":"x"}', + signal: client.signal, + headersTimeoutMs: 10, + fetchFn: fetchFn as unknown as typeof fetch, + }) + + expect(response.status).toBe(200) + expect(receivedSignal).toBe(client.signal) + }) + + it('rethrows non-timeout fetch failures unchanged', async () => { + const failure = new Error('connect ECONNREFUSED') + const fetchFn = vi.fn().mockRejectedValue(failure) + + await expect(forward({ fetchFn: fetchFn as unknown as typeof fetch })).rejects.toBe(failure) + }) + + it('forwards method, headers, and body with a manual redirect and half duplex', async () => { + const fetchFn = vi.fn().mockResolvedValue(new Response('ok', { status: 201, statusText: 'Created' })) + + const response = await forward({ + url: 'http://upstream.example/api/session', + method: 'POST', + headers: { 'content-type': 'application/json', 'x-test': '1' }, + body: '{"title":"x"}', + fetchFn: fetchFn as unknown as typeof fetch, + }) + + const [calledUrl, init] = fetchFn.mock.calls[0] as [string | URL, RequestInit] + expect(calledUrl).toBe('http://upstream.example/api/session') + expect(init.method).toBe('POST') + expect(init.headers).toEqual({ 'content-type': 'application/json', 'x-test': '1' }) + expect(init.body).toBe('{"title":"x"}') + expect(init.redirect).toBe('manual') + expect(init.duplex).toBe('half') + expect(response.status).toBe(201) + expect(response.statusText).toBe('Created') + }) + + it('builds the returned response headers from the upstream response', async () => { + const fetchFn = vi.fn().mockResolvedValue(new Response('ok', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })) + + const response = await forward({ + fetchFn: fetchFn as unknown as typeof fetch, + buildResponseHeaders: (headers) => new Headers({ 'x-proxied': headers.get('content-type') ?? '' }), + }) + + expect(response.headers.get('x-proxied')).toBe('text/event-stream') + }) +}) diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts index a75180a54..9db031351 100644 --- a/frontend/src/api/opencode.test.ts +++ b/frontend/src/api/opencode.test.ts @@ -80,6 +80,7 @@ describe('OpenCode facade', () => { limit: 25, order: 'desc', search: 'deploy', + parentID: null, }) expect(lastRequest().url).toBe( @@ -93,7 +94,7 @@ describe('OpenCode facade', () => { it('passes the directory and cursor for continuation pages', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: [], cursor: {} })) - const page = await listSessionPage({ directory: '/repo', cursor: 'cursor_1' }) + const page = await listSessionPage({ directory: '/repo', cursor: 'cursor_1', parentID: null }) expect(lastRequest().url).toBe( 'http://localhost/api/opencode/api/session?parentID=null&directory=%2Frepo&cursor=cursor_1', @@ -108,7 +109,7 @@ describe('OpenCode facade', () => { jsonResponse({ data: [sessionInfo('ses_1', '/repo')], cursor: { next: 'cursor_next' } }), ) - const page = await listSessionPage({ limit: 25, order: 'desc' }) + const page = await listSessionPage({ limit: 25, order: 'desc', parentID: null }) expect(lastRequest().url).toBe( 'http://localhost/api/opencode/api/session?limit=25&order=desc&parentID=null', @@ -121,7 +122,7 @@ describe('OpenCode facade', () => { it('omits the directory on an all-directories continuation page', async () => { fetchMock.mockResolvedValue(jsonResponse({ data: [], cursor: {} })) - await listSessionPage({ cursor: 'cursor_1' }) + await listSessionPage({ cursor: 'cursor_1', parentID: null }) expect(lastRequest().url).toBe( 'http://localhost/api/opencode/api/session?parentID=null&cursor=cursor_1', @@ -134,7 +135,7 @@ describe('OpenCode facade', () => { jsonResponse({ data: [sessionInfo('ses_child', '/repo')], cursor: {} }), ) - await listSessionPage({ directory: '/repo', limit: 25, order: 'desc', includeChildren: true }) + await listSessionPage({ directory: '/repo', limit: 25, order: 'desc' }) expect(lastRequest().url).toBe( 'http://localhost/api/opencode/api/session?limit=25&order=desc&directory=%2Frepo', @@ -313,7 +314,7 @@ describe('OpenCode facade', () => { }), ) - const files = await findFiles({ directory: '/repo', query: 'index', limit: 20 }) + const files = await findFiles({ location: { directory: '/repo' }, query: 'index', limit: 20 }) expect(lastRequest().url).toBe( 'http://localhost/api/opencode/api/fs/find?location%5Bdirectory%5D=%2Frepo&query=index&type=file&limit=20', @@ -325,7 +326,7 @@ describe('OpenCode facade', () => { it('finds files without a limit when none is given', async () => { fetchMock.mockResolvedValue(jsonResponse({ location: { directory: '/repo' }, data: [] })) - await findFiles({ directory: '/repo', query: 'index' }) + await findFiles({ location: { directory: '/repo' }, query: 'index' }) expect(lastRequest().url).toBe( 'http://localhost/api/opencode/api/fs/find?location%5Bdirectory%5D=%2Frepo&query=index&type=file', diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index f3f8f7835..0a32692a4 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -10,6 +10,7 @@ import type { FormInfo, ModelRef, OpenCodeApi, + PermissionReply, PermissionRequest, SessionGoalRead, SessionInboxCompaction, @@ -146,16 +147,7 @@ export interface SessionPage { nextCursor?: string } -export interface SessionPageInput { - directory?: string - project?: string - subpath?: string - limit?: number - order?: 'asc' | 'desc' - search?: string - cursor?: string - includeChildren?: boolean -} +export type SessionPageInput = NonNullable[0]> export interface CreateSessionInput { directory: string @@ -164,11 +156,7 @@ export interface CreateSessionInput { model?: string } -export interface FindFilesInput { - directory?: string - query: string - limit?: number -} +export type FindFilesInput = Parameters[0] export interface SessionMessagesPage { messages: SessionMessageInfo[] @@ -204,19 +192,7 @@ function buildPromptFields(input: PromptFields) { } export async function listSessionPage(input: SessionPageInput, serverId?: string): Promise { - const { data, cursor } = await callOpenCode((api) => - api.session.list({ - ...(input.directory === undefined ? {} : { directory: input.directory }), - ...(input.project === undefined ? {} : { project: input.project }), - ...(input.subpath === undefined ? {} : { subpath: input.subpath }), - ...(input.includeChildren ? {} : { parentID: 'null' }), - limit: input.limit, - order: input.order, - search: input.search, - cursor: input.cursor, - }), - serverId, - ) + const { data, cursor } = await callOpenCode((api) => api.session.list(input), serverId) return { items: data, nextCursor: cursor.next ?? undefined } } @@ -546,7 +522,7 @@ export async function listPendingPermissions( export async function replyPermission( sessionID: string, requestID: string, - decision: 'once' | 'always' | 'reject', + decision: PermissionReply, message?: string, serverId?: string, ): Promise { @@ -581,16 +557,7 @@ export async function cancelForm(sessionID: string, formID: string, serverId?: s } export async function findFiles(input: FindFilesInput, serverId?: string): Promise { - const { data } = await callOpenCode( - (api) => - api.file.find({ - ...openCodeLocation(input.directory), - query: input.query, - type: 'file', - limit: input.limit, - }), - serverId, - ) + const { data } = await callOpenCode((api) => api.file.find({ ...input, type: 'file' }), serverId) return data.map((entry) => entry.path) } diff --git a/frontend/src/api/providers.test.ts b/frontend/src/api/providers.test.ts index 97c817fe2..3599b9239 100644 --- a/frontend/src/api/providers.test.ts +++ b/frontend/src/api/providers.test.ts @@ -87,8 +87,8 @@ describe('getProviders', () => { expect(result.providers[0]).toMatchObject({ id: 'anthropic', name: 'Anthropic' }) expect(result.providers[0].models).toHaveLength(1) expect(result.providers[0].models[0]).toMatchObject({ - key: 'claude-sonnet-4', - id: 'claude-sonnet-4-20250514', + id: 'claude-sonnet-4', + modelID: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', limit: { context: 200000, output: 64000 }, released: Date.UTC(2025, 4, 22), @@ -132,7 +132,7 @@ describe('getProviders', () => { const result = await getProviders('/repo') expect(result.providers.map((provider) => provider.id)).toEqual(['anthropic']) - expect(result.providers[0].models.map((model) => model.key)).toEqual(['claude-sonnet-4']) + expect(result.providers[0].models.map((model) => model.id)).toEqual(['claude-sonnet-4']) }) it('sorts providers and models in the TUI order', async () => { @@ -288,14 +288,8 @@ describe('saveOpenCodeModelVariant', () => { }) describe('providerModelRef', () => { - it('prefers the catalog key over the backing model id', () => { - expect(providerModelRef({ id: 'anthropic' }, { id: 'claude-sonnet-4-20250514', key: 'claude-sonnet-4' })).toBe( - 'anthropic/claude-sonnet-4' - ) - }) - - it('falls back to the model id when no key is present', () => { - expect(providerModelRef({ id: 'openai' }, { id: 'gpt-4o' })).toBe('openai/gpt-4o') + it('formats the provider and catalog model id as an OpenCode model ref', () => { + expect(providerModelRef({ id: 'anthropic' }, { id: 'claude-sonnet-4' })).toBe('anthropic/claude-sonnet-4') }) }) diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index e1906dfdc..ad4fb25d4 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -18,17 +18,14 @@ import { } from "@opencode-manager/shared/opencode"; import type { CredentialListResponse, CredentialStatusResponse } from "@opencode-manager/shared/schemas"; -export interface Model { - id: string; - key?: string; - name: string; +export type Model = Pick & { limit?: { context: number; output: number; }; released: number; free: boolean; -} +}; export interface Provider { id: string; @@ -47,8 +44,8 @@ export type OpenCodeModelState = ModelPreference function mapModelInfo(model: ModelInfo): Model { return { - id: model.modelID, - key: model.id, + id: model.id, + modelID: model.modelID, name: model.name, limit: { context: model.limit.context, @@ -167,11 +164,11 @@ export async function toggleOpenCodeFavoriteModel(model: ModelSelection): Promis } export function formatModelName(model: Model): string { - return model.name || model.id; + return model.name || model.modelID; } -export function providerModelRef(provider: { id: string }, model: { id: string; key?: string }): string { - return formatOpenCodeModelRef({ providerID: provider.id, id: model.key ?? model.id }); +export function providerModelRef(provider: { id: string }, model: { id: string }): string { + return formatOpenCodeModelRef({ providerID: provider.id, id: model.id }); } export function modelSelectionRef(model: ModelSelection): string { diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 6357bf568..c7ec8428a 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -14,6 +14,8 @@ import type { InstallSkillResponse, OpenCodeDirectoryFileInfo, OpenCodeRestartResponse, + ManagedOpenCodeConfig, + UpdateOpenCodeConfigPatchRequest, } from './types/settings' import { API_BASE_URL } from '@/config' import { fetchWrapper } from './fetchWrapper' @@ -73,6 +75,20 @@ export const settingsApi = { }) }, + getManagedOpenCodeConfig: async (): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-config/managed`) + }, + + patchOpenCodeConfig: async ( + request: UpdateOpenCodeConfigPatchRequest + ): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-config`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + }, + discoverOpenCodeModels: async ( baseUrl: string, apiKey?: string, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 47d452177..5a9f0515d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -22,7 +22,6 @@ export interface Repo { } export type Session = SessionInfo -export type PermissionResponse = 'once' | 'always' | 'reject' export interface FileAttachmentInfo { path: string @@ -36,15 +35,3 @@ export interface ImageAttachment { mime: string dataUrl: string } - -export interface SSHHostKeyRequest { - id: string - requestId: string - host: string - ip?: string - keyType: string - fingerprint: string - isKeyChanged?: boolean - timestamp: number - action: 'verify' -} diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index dd20feaa8..6fa0bf31d 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -15,8 +15,7 @@ import { type OpenCodeConfigSourceFile, type OpenCodeConfigSourceName, type UpdateOpenCodeConfigRequest, - type ModelConfig, - type ProviderConfig, + type UpdateOpenCodeConfigPatchRequest, type SandboxPreferences, type SkillFileInfo, type CreateSkillRequest, @@ -26,10 +25,17 @@ import { type InstallSkillResponse, type SettingsResponse as SharedSettingsResponse, } from '@opencode-manager/shared' +import type { OpenCodeConfigInfo } from '@opencode-manager/shared/opencode' +import type { + OpenCodeAgentConfig, + OpenCodeCommandConfig, + OpenCodeProviderConfig, + OpenCodeProviderModelConfig, +} from '@opencode-manager/shared/opencode' import type { NotificationPreferences } from '@opencode-manager/shared/types' import { saveFile } from '@/lib/download' -export type { TTSConfig, STTConfig, OpenCodeConfigFile, OpenCodeConfigSourceFile, OpenCodeConfigSourceName, UpdateOpenCodeConfigRequest, ModelConfig, ProviderConfig, SandboxPreferences, NotificationPreferences, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, SkillScope, InstallSkillFromGithubRequest, InstallSkillResponse } +export type { TTSConfig, STTConfig, OpenCodeConfigFile, OpenCodeConfigSourceFile, OpenCodeConfigSourceName, UpdateOpenCodeConfigRequest, UpdateOpenCodeConfigPatchRequest, SandboxPreferences, NotificationPreferences, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, SkillScope, InstallSkillFromGithubRequest, InstallSkillResponse } export type { UserPreferences, UpdateSettingsRequest, CustomCommand, GitCredential, GitIdentity, GitIdentityProfile, KeyboardShortcutAction } from '@opencode-manager/shared' export { DEFAULT_TTS_CONFIG, DEFAULT_STT_CONFIG, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_USER_PREFERENCES, DEFAULT_LEADER_KEY, DEFAULT_DIRECT_SHORTCUTS, BLOCKED_SERVER_ENV_KEYS, DEFAULT_ACTIVITY_WINDOW_MINUTES, ACTIVITY_WINDOW_MINUTE_OPTIONS } export { isOpenCodeConfigSourceName } from '@opencode-manager/shared' @@ -90,3 +96,41 @@ export interface OpenCodeDirectoryFileInfo { name: string relativePath: string } + +export interface ManagedOpenCodeConfig { + info: OpenCodeConfigInfo +} + +/** + * Merge-patch value for one agent: an explicit null clears the stored field, + * while an omitted field stays untouched. + */ +export type OpenCodeAgentPatch = { + [K in keyof OpenCodeAgentConfig]?: OpenCodeAgentConfig[K] | null +} + +/** + * Merge-patch value for one command: an explicit null clears the stored field, + * while an omitted field stays untouched. + */ +export type OpenCodeCommandPatch = { + [K in keyof OpenCodeCommandConfig]?: OpenCodeCommandConfig[K] | null +} + +/** + * Merge-patch value for one provider model: an explicit null clears the stored + * field, while an omitted field stays untouched. + */ +export type OpenCodeProviderModelPatch = { + [K in keyof OpenCodeProviderModelConfig]?: OpenCodeProviderModelConfig[K] | null +} + +/** + * Merge-patch value for one provider: an explicit null clears the stored field, + * a null model entry removes that model, and omitted entries stay untouched. + */ +export type OpenCodeProviderPatch = { + [K in keyof OpenCodeProviderConfig]?: K extends 'models' + ? Record | null + : OpenCodeProviderConfig[K] | null +} diff --git a/frontend/src/components/agent/AgentQuickSelect.tsx b/frontend/src/components/agent/AgentQuickSelect.tsx index 56f38aacc..cbbe2773b 100644 --- a/frontend/src/components/agent/AgentQuickSelect.tsx +++ b/frontend/src/components/agent/AgentQuickSelect.tsx @@ -8,6 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import type { AgentInfo } from '@opencode-manager/shared/opencode' import { useAgents } from '@/hooks/useOpenCode' import { getAgentStyleVars } from '@/lib/agent-colors' import { getPrimaryAgents } from '@/lib/primaryAgents' @@ -19,16 +20,9 @@ interface AgentQuickSelectProps { isBashMode?: boolean } -interface AgentInfo { - id: string - name: string - color?: string - description?: string - mode?: string - hidden?: boolean -} +type SelectableAgent = Pick -const findAgent = (agents: AgentInfo[], agentId: string): AgentInfo | undefined => { +const findAgent = (agents: SelectableAgent[], agentId: string): SelectableAgent | undefined => { return agents.find(a => a.id.toLowerCase() === agentId.toLowerCase()) } diff --git a/frontend/src/components/model/ModelCombobox.test.tsx b/frontend/src/components/model/ModelCombobox.test.tsx index d5a6822b6..b22ff093d 100644 --- a/frontend/src/components/model/ModelCombobox.test.tsx +++ b/frontend/src/components/model/ModelCombobox.test.tsx @@ -24,17 +24,17 @@ const providers: Provider[] = [ id: 'anthropic', name: 'Anthropic', models: [ - { id: 'claude-opus-5-5', key: 'claude-opus-5-5-fast', name: 'Claude Opus 5.5', released: 0, free: false }, - { id: 'claude-sonnet', key: 'claude-sonnet', name: 'Claude Sonnet', released: 0, free: false }, - { id: 'claude-haiku', key: 'claude-haiku', name: 'Claude Haiku', released: 0, free: false }, + { id: 'claude-opus-5-5-fast', modelID: 'claude-opus-5-5', name: 'Claude Opus 5.5', released: 0, free: false }, + { id: 'claude-sonnet', modelID: 'claude-sonnet', name: 'Claude Sonnet', released: 0, free: false }, + { id: 'claude-haiku', modelID: 'claude-haiku', name: 'Claude Haiku', released: 0, free: false }, ], }, { id: 'openai', name: 'OpenAI', models: [ - { id: 'gpt-5', key: 'gpt-5', name: 'GPT-5', released: 0, free: false }, - { id: 'gpt-4o', key: 'gpt-4o', name: 'GPT-4o', released: 0, free: false }, + { id: 'gpt-5', modelID: 'gpt-5', name: 'GPT-5', released: 0, free: false }, + { id: 'gpt-4o', modelID: 'gpt-4o', name: 'GPT-4o', released: 0, free: false }, ], }, ] diff --git a/frontend/src/components/model/ModelQuickSelect.test.tsx b/frontend/src/components/model/ModelQuickSelect.test.tsx index a179016f0..3b577ac9c 100644 --- a/frontend/src/components/model/ModelQuickSelect.test.tsx +++ b/frontend/src/components/model/ModelQuickSelect.test.tsx @@ -43,25 +43,25 @@ const providers: Provider[] = [ { id: 'opencode-go', name: 'OpenCode Go', - models: [{ id: 'go-1', key: 'go-1', name: 'Go Model One', released: 0, free: false }], + models: [{ id: 'go-1', modelID: 'go-1', name: 'Go Model One', released: 0, free: false }], }, { id: 'opencode', name: 'OpenCode', - models: [{ id: 'zen-1', key: 'zen-1', name: 'Zen One', released: 0, free: false }], + models: [{ id: 'zen-1', modelID: 'zen-1', name: 'Zen One', released: 0, free: false }], }, { id: 'anthropic', name: 'Anthropic', models: [ - { id: 'claude-sonnet-4', key: 'claude-sonnet-4', name: 'Claude Sonnet 4', released: 0, free: false }, - { id: 'claude-opus-5-5', key: 'claude-opus-5-5-fast', name: 'Claude Opus 5.5', released: 0, free: false }, + { id: 'claude-sonnet-4', modelID: 'claude-sonnet-4', name: 'Claude Sonnet 4', released: 0, free: false }, + { id: 'claude-opus-5-5-fast', modelID: 'claude-opus-5-5', name: 'Claude Opus 5.5', released: 0, free: false }, ], }, { id: 'openai', name: 'OpenAI', - models: [{ id: 'gpt-5', key: 'gpt-5', name: 'GPT-5', released: 0, free: false }], + models: [{ id: 'gpt-5', modelID: 'gpt-5', name: 'GPT-5', released: 0, free: false }], }, ] @@ -642,7 +642,7 @@ describe('ModelQuickSelect model descriptions', () => { models: [ { id: 'claude-sonnet-4', - key: 'claude-sonnet-4', + modelID: 'claude-sonnet-4', name: 'Claude Sonnet 4', released: 0, free: false, @@ -650,7 +650,7 @@ describe('ModelQuickSelect model descriptions', () => { }, { id: 'claude-opus-5-5', - key: 'claude-opus-5-5', + modelID: 'claude-opus-5-5', name: 'Claude Opus 5.5', released: 0, free: false, diff --git a/frontend/src/components/repo/MultiRunSheet.test.tsx b/frontend/src/components/repo/MultiRunSheet.test.tsx index c02b5a9f9..1a106b37c 100644 --- a/frontend/src/components/repo/MultiRunSheet.test.tsx +++ b/frontend/src/components/repo/MultiRunSheet.test.tsx @@ -67,19 +67,19 @@ const providers = [ id: 'openai', name: 'OpenAI', models: [ - { id: 'gpt-4o', name: 'GPT-4o', released: 0, free: false }, - { id: 'gpt-4o-mini', name: 'GPT-4o mini', released: 0, free: false }, - { id: 'gpt-4.1', name: 'GPT-4.1', released: 0, free: false }, + { id: 'gpt-4o', modelID: 'gpt-4o', name: 'GPT-4o', released: 0, free: false }, + { id: 'gpt-4o-mini', modelID: 'gpt-4o-mini', name: 'GPT-4o mini', released: 0, free: false }, + { id: 'gpt-4.1', modelID: 'gpt-4.1', name: 'GPT-4.1', released: 0, free: false }, ], }, { id: 'anthropic', name: 'Anthropic', models: [ - { id: 'claude-opus', name: 'Claude Opus', released: 0, free: false }, - { id: 'claude-sonnet', name: 'Claude Sonnet', released: 0, free: false }, - { id: 'claude-haiku', name: 'Claude Haiku', released: 0, free: false }, - { id: 'claude-sonnet-4-5', key: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', released: 0, free: false }, + { id: 'claude-opus', modelID: 'claude-opus', name: 'Claude Opus', released: 0, free: false }, + { id: 'claude-sonnet', modelID: 'claude-sonnet', name: 'Claude Sonnet', released: 0, free: false }, + { id: 'claude-haiku', modelID: 'claude-haiku', name: 'Claude Haiku', released: 0, free: false }, + { id: 'claude-sonnet-4.5', modelID: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', released: 0, free: false }, ], }, ] diff --git a/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx index 235c7b4a6..2747a99ca 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.model.test.tsx @@ -55,7 +55,7 @@ const providers: Provider[] = [ { id: 'openai', name: 'OpenAI', - models: [{ id: 'gpt-5', key: 'gpt-5', name: 'GPT-5', released: 0, free: false }], + models: [{ id: 'gpt-5', modelID: 'gpt-5', name: 'GPT-5', released: 0, free: false }], }, ] @@ -206,8 +206,8 @@ describe('ScheduleJobDialog — model fallback', () => { id: 'openai', name: 'OpenAI', models: [ - { id: 'gpt-5', key: 'gpt-5', name: 'GPT-5', released: 0, free: false }, - { id: 'gpt-4o', key: 'gpt-4o', name: 'GPT-4o', released: 0, free: false }, + { id: 'gpt-5', modelID: 'gpt-5', name: 'GPT-5', released: 0, free: false }, + { id: 'gpt-4o', modelID: 'gpt-4o', name: 'GPT-4o', released: 0, free: false }, ], }, ], diff --git a/frontend/src/components/session/PermissionRequestDialog.tsx b/frontend/src/components/session/PermissionRequestDialog.tsx index db48844cb..6f53c9132 100644 --- a/frontend/src/components/session/PermissionRequestDialog.tsx +++ b/frontend/src/components/session/PermissionRequestDialog.tsx @@ -9,8 +9,7 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' -import type { PermissionResponse } from '@/api/types' -import type { PermissionRequest } from '@opencode-manager/shared/opencode' +import type { PermissionReply, PermissionRequest } from '@opencode-manager/shared/opencode' import { getPermissionLabel, getPermissionPhrase, @@ -30,7 +29,7 @@ interface PermissionRequestDialogProps { onRespond: ( permissionID: string, sessionID: string, - response: PermissionResponse, + response: PermissionReply, message?: string, ) => Promise open?: boolean @@ -49,7 +48,7 @@ export function PermissionRequestDialog({ onOpenChange, }: PermissionRequestDialogProps) { const [isLoading, setIsLoading] = useState(false) - const [loadingAction, setLoadingAction] = useState(null) + const [loadingAction, setLoadingAction] = useState(null) const [rejectReason, setRejectReason] = useState('') const reasonInputId = useId() const allowOnceRef = useRef(null) @@ -61,7 +60,7 @@ export function PermissionRequestDialog({ if (!permission) return null if (parentOpen === false) return null - const handleResponse = async (response: PermissionResponse) => { + const handleResponse = async (response: PermissionReply) => { setIsLoading(true) setLoadingAction(response) const message = response === 'reject' ? rejectReason.trim() : '' diff --git a/frontend/src/components/settings/AgentDialog.test.tsx b/frontend/src/components/settings/AgentDialog.test.tsx index 0a8ae007d..3f0f51f47 100644 --- a/frontend/src/components/settings/AgentDialog.test.tsx +++ b/frontend/src/components/settings/AgentDialog.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AgentDialog } from './AgentDialog' @@ -24,12 +24,19 @@ const providers: Provider[] = [ id: 'anthropic', name: 'Anthropic', models: [ - { id: 'claude-opus-5-5', key: 'claude-opus-5-5-fast', name: 'Claude Opus 5.5', released: 0, free: false }, + { id: 'claude-opus-5-5-fast', modelID: 'claude-opus-5-5', name: 'Claude Opus 5.5', released: 0, free: false }, ], }, ] -describe('AgentDialog model field', () => { +describe('AgentDialog', () => { + beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.setPointerCapture ??= () => {} + Element.prototype.releasePointerCapture ??= () => {} + Element.prototype.scrollIntoView ??= () => {} + }) + beforeEach(() => { vi.clearAllMocks() mocks.useProviders.mockReturnValue({ data: { providers, models: [] } }) @@ -43,7 +50,7 @@ describe('AgentDialog model field', () => { open onOpenChange={vi.fn()} onSubmit={vi.fn()} - editingAgent={{ name: 'reviewer', agent: { prompt: 'Review code', model: 'openai/gpt-4o' } }} + editingAgent={{ name: 'reviewer', agent: { system: 'Review code', model: 'openai/gpt-4o' } }} />, ) @@ -58,7 +65,7 @@ describe('AgentDialog model field', () => { open onOpenChange={vi.fn()} onSubmit={onSubmit} - editingAgent={{ name: 'reviewer', agent: { prompt: 'Review code', model: 'openai/gpt-4o#high' } }} + editingAgent={{ name: 'reviewer', agent: { system: 'Review code', model: 'openai/gpt-4o#high' } }} />, ) @@ -80,7 +87,7 @@ describe('AgentDialog model field', () => { onSubmit={onSubmit} editingAgent={{ name: 'reviewer', - agent: { prompt: 'Review code', model: { providerID: 'openai', model: 'gpt-4o', variant: 'high' } }, + agent: { system: 'Review code', model: { providerID: 'openai', model: 'gpt-4o', variant: 'high' } }, }} />, ) @@ -95,7 +102,7 @@ describe('AgentDialog model field', () => { ) }) - it('submits no model when the field is empty', async () => { + it('submits null for a cleared model', async () => { const onSubmit = vi.fn() const user = userEvent.setup() render( @@ -103,14 +110,39 @@ describe('AgentDialog model field', () => { open onOpenChange={vi.fn()} onSubmit={onSubmit} - editingAgent={{ name: 'reviewer', agent: { prompt: 'Review code' } }} + editingAgent={{ name: 'reviewer', agent: { system: 'Review code' } }} />, ) await user.click(screen.getByRole('button', { name: 'Update' })) const agent = onSubmit.mock.calls[0][1] - expect(agent.model).toBeUndefined() + expect(agent.model).toBeNull() + }) + + it('sends null for cleared description and model so the merge patch deletes them', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + await user.clear(screen.getByLabelText('Description')) + await user.clear(screen.getByLabelText('Model')) + await user.click(screen.getByRole('button', { name: 'Update' })) + + expect(onSubmit).toHaveBeenCalledWith( + 'reviewer', + expect.objectContaining({ description: null, model: null }), + ) }) it('saves the OpenCode key of an aliased model on create', async () => { @@ -129,4 +161,160 @@ describe('AgentDialog model field', () => { expect.objectContaining({ model: 'anthropic/claude-opus-5-5-fast' }), ) }) + + it('maps v2 request body values and permission rules into the form and preserves unrelated fields on save', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + expect(screen.getByLabelText('Temperature')).toHaveValue(0.2) + expect(screen.getByLabelText('Top P')).toHaveValue(0.5) + expect(screen.getByText('Deny')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.request).toEqual({ + headers: { 'x-test': '1' }, + body: { temperature: 0.2, top_p: 0.5, reasoning_effort: 'high' }, + }) + expect(agent.permissions).toEqual([ + { action: 'edit', resource: '*', effect: 'allow' }, + { action: 'shell', resource: '*', effect: 'deny' }, + { action: 'edit', resource: 'src/**', effect: 'ask' }, + { action: 'webfetch', resource: '*', effect: 'allow' }, + ]) + expect(agent.hidden).toBe(true) + expect(agent.color).toBe('#fff') + expect(agent.steps).toBe(7) + }) + + it('keeps a wildcard rule before a specific rule and writes the form effect in place', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: 'Edit' })) + await user.click(screen.getByRole('option', { name: 'Ask' })) + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.permissions).toEqual([ + { action: 'shell', resource: '*', effect: 'allow' }, + { action: 'webfetch', resource: '*', effect: 'allow' }, + { action: 'edit', resource: '*', effect: 'ask' }, + { action: 'edit', resource: 'src/**', effect: 'allow' }, + ]) + }) + + it('prepends wildcard rules for managed actions that have none', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.permissions).toEqual([ + { action: 'edit', resource: '*', effect: 'allow' }, + { action: 'shell', resource: '*', effect: 'allow' }, + { action: 'webfetch', resource: '*', effect: 'allow' }, + { action: 'edit', resource: 'src/**', effect: 'deny' }, + { action: 'shell', resource: 'git status', effect: 'ask' }, + ]) + }) + + it('shows the last matching wildcard effect and applies the form effect to every occurrence', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + expect(screen.getByRole('combobox', { name: 'Edit' })).toHaveTextContent('Deny') + + await user.click(screen.getByRole('combobox', { name: 'Edit' })) + await user.click(screen.getByRole('option', { name: 'Ask' })) + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.permissions).toEqual([ + { action: 'shell', resource: '*', effect: 'allow' }, + { action: 'webfetch', resource: '*', effect: 'allow' }, + { action: 'edit', resource: '*', effect: 'ask' }, + { action: 'edit', resource: '*', effect: 'ask' }, + ]) + }) }) diff --git a/frontend/src/components/settings/AgentDialog.tsx b/frontend/src/components/settings/AgentDialog.tsx index e8772c245..8a868c32b 100644 --- a/frontend/src/components/settings/AgentDialog.tsx +++ b/frontend/src/components/settings/AgentDialog.tsx @@ -11,6 +11,64 @@ import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { ModelCombobox } from '@/components/model/ModelCombobox' import { configModelRef, formatOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import type { OpenCodeAgentConfig, OpenCodePermissionRule } from '@opencode-manager/shared/opencode' +import type { OpenCodeAgentPatch } from '@/api/types/settings' + +const MANAGED_PERMISSION_ACTIONS = ['edit', 'shell', 'webfetch'] as const + +type ManagedPermissionAction = typeof MANAGED_PERMISSION_ACTIONS[number] + +type PermissionEffect = OpenCodePermissionRule['effect'] + +const isManagedWildcardRule = (rule: OpenCodePermissionRule): boolean => + rule.resource === '*' && MANAGED_PERMISSION_ACTIONS.includes(rule.action as ManagedPermissionAction) + +const lastMatchingRule = ( + permissions: OpenCodePermissionRule[] | undefined, + matches: (rule: OpenCodePermissionRule) => boolean, +): OpenCodePermissionRule | undefined => { + let match: OpenCodePermissionRule | undefined + for (const rule of permissions ?? []) { + if (matches(rule)) { + match = rule + } + } + return match +} + +const permissionEffect = ( + permissions: OpenCodePermissionRule[] | undefined, + action: ManagedPermissionAction, +): PermissionEffect => { + const rule = lastMatchingRule(permissions, (entry) => entry.action === action && entry.resource === '*') + return rule?.effect ?? 'allow' +} + +/** + * Applies the form effects to the managed wildcard rules in place, so the rule + * order OpenCode resolves with last-match-wins stays unchanged, and prepends a + * wildcard fallback for managed actions that have no wildcard rule yet. + */ +const withManagedPermissionEffects = ( + permissions: OpenCodePermissionRule[] | undefined, + effects: Record, +): OpenCodePermissionRule[] => { + const updated = (permissions ?? []).map((rule) => + isManagedWildcardRule(rule) + ? { ...rule, effect: effects[rule.action as ManagedPermissionAction] } + : rule, + ) + const actionsWithWildcard = new Set(updated.filter(isManagedWildcardRule).map((rule) => rule.action)) + const fallbacks = MANAGED_PERMISSION_ACTIONS + .filter((action) => !actionsWithWildcard.has(action)) + .map((action) => ({ action, resource: '*', effect: effects[action] })) + return [...fallbacks, ...updated] +} + +const requestBodyNumber = (agent: OpenCodeAgentConfig | undefined, key: string): number | undefined => { + const value = agent?.request?.body?.[key] + return typeof value === 'number' ? value : undefined +} const agentFormSchema = z.object({ name: z.string().min(1, 'Agent name is required').regex(/^[a-z0-9-]+$/, 'Must be lowercase letters, numbers, and hyphens only'), @@ -20,70 +78,42 @@ const agentFormSchema = z.object({ temperature: z.number().min(0).max(2), topP: z.number().min(0).max(1), model: z.string().optional(), - write: z.boolean(), - edit: z.boolean(), - bash: z.boolean(), - webfetch: z.boolean(), editPermission: z.enum(['ask', 'allow', 'deny']), - bashPermission: z.enum(['ask', 'allow', 'deny']), + shellPermission: z.enum(['ask', 'allow', 'deny']), webfetchPermission: z.enum(['ask', 'allow', 'deny']), - disable: z.boolean() + disable: z.boolean(), }) type AgentFormValues = z.infer -interface Agent { - prompt?: string - description?: string - mode?: 'subagent' | 'primary' | 'all' - temperature?: number - topP?: number - top_p?: number - model?: TModel - tools?: Record - permission?: { - edit?: 'ask' | 'allow' | 'deny' - bash?: 'ask' | 'allow' | 'deny' | Record - webfetch?: 'ask' | 'allow' | 'deny' - } - disable?: boolean - [key: string]: unknown -} - -type StoredAgent = Agent - interface AgentDialogProps { open: boolean onOpenChange: (open: boolean) => void - onSubmit: (name: string, agent: Agent) => void - editingAgent?: { name: string; agent: StoredAgent } | null + onSubmit: (name: string, agent: OpenCodeAgentPatch) => void + editingAgent?: { name: string; agent: OpenCodeAgentConfig } | null } export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: AgentDialogProps) { - const getDefaultValues = (agent?: { name: string; agent: StoredAgent } | null): AgentFormValues => { + const getDefaultValues = (agent?: { name: string; agent: OpenCodeAgentConfig } | null): AgentFormValues => { const modelRef = configModelRef(agent?.agent.model) return { name: agent?.name || '', description: agent?.agent.description || '', - prompt: agent?.agent.prompt || '', + prompt: agent?.agent.system || '', mode: agent?.agent.mode || 'subagent', - temperature: agent?.agent.temperature ?? 0.7, - topP: agent?.agent.topP ?? agent?.agent.top_p ?? 1, + temperature: requestBodyNumber(agent?.agent, 'temperature') ?? 0.7, + topP: requestBodyNumber(agent?.agent, 'top_p') ?? 1, model: modelRef ? formatOpenCodeModelRef(modelRef) : '', - write: agent?.agent.tools?.write ?? true, - edit: agent?.agent.tools?.edit ?? true, - bash: agent?.agent.tools?.bash ?? true, - webfetch: agent?.agent.tools?.webfetch ?? true, - editPermission: agent?.agent.permission?.edit ?? 'allow', - bashPermission: typeof agent?.agent.permission?.bash === 'string' ? agent.agent.permission.bash : 'allow', - webfetchPermission: agent?.agent.permission?.webfetch ?? 'allow', - disable: agent?.agent.disable ?? false + editPermission: permissionEffect(agent?.agent.permissions, 'edit'), + shellPermission: permissionEffect(agent?.agent.permissions, 'shell'), + webfetchPermission: permissionEffect(agent?.agent.permissions, 'webfetch'), + disable: agent?.agent.disabled ?? false, } } const form = useForm({ resolver: zodResolver(agentFormSchema), - defaultValues: getDefaultValues(editingAgent) + defaultValues: getDefaultValues(editingAgent), }) useEffect(() => { @@ -93,28 +123,27 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen }, [open, editingAgent, form]) const handleSubmit = (values: AgentFormValues) => { - const agent: Agent = { - prompt: values.prompt, - description: values.description || undefined, + const previous = editingAgent?.agent + const agent: OpenCodeAgentPatch = { + ...previous, + system: values.prompt, + description: values.description || null, mode: values.mode, - temperature: values.temperature, - topP: values.topP, - disable: values.disable, - tools: { - write: values.write, - edit: values.edit, - bash: values.bash, - webfetch: values.webfetch - }, - permission: { + model: values.model?.trim() || null, + disabled: values.disable, + permissions: withManagedPermissionEffects(previous?.permissions, { edit: values.editPermission, - bash: values.bashPermission, - webfetch: values.webfetchPermission - } - } - - if (values.model?.trim()) { - agent.model = values.model.trim() + shell: values.shellPermission, + webfetch: values.webfetchPermission, + }), + request: { + ...previous?.request, + body: { + ...previous?.request?.body, + temperature: values.temperature, + top_p: values.topP, + }, + }, } onSubmit(values.name, agent) @@ -287,75 +316,6 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen )} /> -
-
Tools Configuration
-
- ( - - - - - Write - - )} - /> - - ( - - - - - Edit - - )} - /> - - ( - - - - - Bash - - )} - /> - - ( - - - - - Web Fetch - - )} - /> -
-
-
Permissions
@@ -384,10 +344,10 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen ( - Bash + Shell - field.onChange(parseFloat(e.target.value)) - } - /> - - - - )} - /> (
- Run as subtask + Run as subagent - Execute this command as a separate subtask + Execute this command in a subagent session
@@ -262,4 +246,3 @@ export function CommandDialog({ ); } - diff --git a/frontend/src/components/settings/CommandsEditor.test.tsx b/frontend/src/components/settings/CommandsEditor.test.tsx index 2b0b9e9bb..fb2a8eca1 100644 --- a/frontend/src/components/settings/CommandsEditor.test.tsx +++ b/frontend/src/components/settings/CommandsEditor.test.tsx @@ -54,16 +54,14 @@ describe('CommandsEditor', () => { }) it('renders empty state when no commands configured', () => { - const onChange = vi.fn() - render(, { wrapper: createWrapper() }) + render(, { wrapper: createWrapper() }) expect(screen.getByText('No commands configured')).toBeInTheDocument() expect(screen.getByText('Add your first command to get started.')).toBeInTheDocument() }) it('renders command names without a leading slash', () => { - const onChange = vi.fn() - render(, { wrapper: createWrapper() }) + render(, { wrapper: createWrapper() }) expect(screen.getByText('review')).toBeInTheDocument() expect(screen.getByText('build')).toBeInTheDocument() @@ -71,12 +69,12 @@ describe('CommandsEditor', () => { }) it('renders uploaded directory command files', () => { - const onChange = vi.fn() render( , { wrapper: createWrapper() }, ) @@ -89,8 +87,7 @@ describe('CommandsEditor', () => { it('opens CommandDialog when clicking Edit on a row', async () => { const user = userEvent.setup() - const onChange = vi.fn() - render(, { wrapper: createWrapper() }) + render(, { wrapper: createWrapper() }) const editButtons = screen.getAllByText('Edit') await user.click(editButtons[0]) @@ -101,8 +98,7 @@ describe('CommandsEditor', () => { it('opens CommandDialog when clicking row body', async () => { const user = userEvent.setup() - const onChange = vi.fn() - render(, { wrapper: createWrapper() }) + render(, { wrapper: createWrapper() }) await user.click(screen.getByText('review')) @@ -111,8 +107,7 @@ describe('CommandsEditor', () => { it('opens CommandDialog and displays Create Command text when clicking Add Command button', async () => { const user = userEvent.setup() - const onChange = vi.fn() - render(, { wrapper: createWrapper() }) + render(, { wrapper: createWrapper() }) await user.click(screen.getByText('Add Command')) @@ -120,33 +115,31 @@ describe('CommandsEditor', () => { expect(screen.getByText('Create Command')).toBeInTheDocument() }) - it('calls onChange with command removed when Delete is clicked from overflow menu', async () => { + it('calls onDelete with the command name when Delete is clicked from overflow menu', async () => { const user = userEvent.setup() - const onChange = vi.fn() - render(, { wrapper: createWrapper() }) + const onDelete = vi.fn() + render(, { wrapper: createWrapper() }) await user.click(screen.getByLabelText('Actions for review')) await user.click(screen.getByText('Delete')) - expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith({ build: mockCommands.build }) + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onDelete).toHaveBeenCalledWith('review') }) it('renders agent badge when command has agent', () => { - const onChange = vi.fn() - render(, { wrapper: createWrapper() }) + render(, { wrapper: createWrapper() }) expect(screen.getByText('code-reviewer')).toBeInTheDocument() }) it('uploads only markdown files from a selected commands folder', async () => { - const onChange = vi.fn() const markdownFile = new File(['commit body'], 'commit.md', { type: 'text/markdown' }) const systemFile = new File(['metadata'], '.DS_Store') Object.defineProperty(markdownFile, 'webkitRelativePath', { value: 'commands/git/commit.md' }) Object.defineProperty(systemFile, 'webkitRelativePath', { value: 'commands/.DS_Store' }) - const { container } = render(, { wrapper: createWrapper() }) + const { container } = render(, { wrapper: createWrapper() }) const input = container.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(input, { target: { files: [markdownFile, systemFile] } }) @@ -158,11 +151,10 @@ describe('CommandsEditor', () => { }) it('shows an error when a selected commands folder has no markdown files', async () => { - const onChange = vi.fn() const systemFile = new File(['metadata'], '.DS_Store') Object.defineProperty(systemFile, 'webkitRelativePath', { value: 'commands/.DS_Store' }) - const { container } = render(, { wrapper: createWrapper() }) + const { container } = render(, { wrapper: createWrapper() }) const input = container.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(input, { target: { files: [systemFile] } }) diff --git a/frontend/src/components/settings/CommandsEditor.tsx b/frontend/src/components/settings/CommandsEditor.tsx index 721d37dc0..f76e1867b 100644 --- a/frontend/src/components/settings/CommandsEditor.tsx +++ b/frontend/src/components/settings/CommandsEditor.tsx @@ -1,58 +1,34 @@ import { useState } from 'react' import { Plus } from 'lucide-react' -import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { Dialog, DialogTrigger } from '@/components/ui/dialog' import { SettingsList, SettingsListRow } from '@/components/ui/settings-list' +import type { OpenCodeCommandConfig } from '@opencode-manager/shared/opencode' +import type { OpenCodeCommandPatch, OpenCodeDirectoryFileInfo } from '@/api/types/settings' import { CommandDialog } from './CommandDialog' import { UploadFolderButton } from './UploadFolderButton' import { DirectoryFilesList } from './DirectoryFilesList' -import type { OpenCodeDirectoryFileInfo } from '@/api/types/settings' - -interface Command { - template: string - description?: string - agent?: string - model?: string - subtask?: boolean - topP?: number -} interface CommandsEditorProps { - commands: Record + commands: Record directoryCommands?: OpenCodeDirectoryFileInfo[] - onChange: (commands: Record) => void + onSave: (name: string, command: OpenCodeCommandPatch, previousName?: string) => void + onDelete: (name: string) => void } -export function CommandsEditor({ commands, directoryCommands = [], onChange }: CommandsEditorProps) { +export function CommandsEditor({ commands, directoryCommands = [], onSave, onDelete }: CommandsEditorProps) { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) - const [editingCommand, setEditingCommand] = useState<{ name: string; command: Command } | null>(null) + const [editingCommand, setEditingCommand] = useState<{ name: string; command: OpenCodeCommandConfig } | null>(null) const hasCommands = Object.keys(commands).length > 0 || directoryCommands.length > 0 - const handleCommandSubmit = (name: string, command: Command) => { - if (editingCommand) { - const updatedCommands = { ...commands } - delete updatedCommands[editingCommand.name] - updatedCommands[name] = command - onChange(updatedCommands) - setEditingCommand(null) - } else { - const updatedCommands = { - ...commands, - [name]: command - } - onChange(updatedCommands) - setIsCreateDialogOpen(false) - } - } - - const deleteCommand = (name: string) => { - const updatedCommands = { ...commands } - delete updatedCommands[name] - onChange(updatedCommands) + const handleCommandSubmit = (name: string, command: OpenCodeCommandPatch) => { + onSave(name, command, editingCommand?.name) + setEditingCommand(null) + setIsCreateDialogOpen(false) } - const startEdit = (name: string, command: Command) => { + const startEdit = (name: string, command: OpenCodeCommandConfig) => { setEditingCommand({ name, command }) } @@ -91,7 +67,7 @@ export function CommandsEditor({ commands, directoryCommands = [], onChange }: C } onClick={() => startEdit(name, command)} primaryAction={{ label: 'Edit', onClick: () => startEdit(name, command) }} - actions={[{ label: 'Delete', destructive: true, onClick: () => deleteCommand(name) }]} + actions={[{ label: 'Delete', destructive: true, onClick: () => onDelete(name) }]} actionsLabel={`Actions for ${name}`} /> ))} diff --git a/frontend/src/components/settings/McpManager.test.tsx b/frontend/src/components/settings/McpManager.test.tsx index f026c0ade..4012f56d8 100644 --- a/frontend/src/components/settings/McpManager.test.tsx +++ b/frontend/src/components/settings/McpManager.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { FetchError } from '@opencode-manager/shared' +import type { McpServerConfig } from '@opencode-manager/shared/opencode' import { McpManager } from './McpManager' const { mockRemoveServer, mockToastError, mockStatus } = vi.hoisted(() => ({ @@ -34,21 +35,20 @@ vi.mock('@/lib/toast', () => ({ showToast: { success: vi.fn(), error: mockToastError, info: vi.fn(), loading: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, })) -const config = { - content: { - mcp: { - servers: { - configured: { type: 'local', command: ['npx', 'server'] }, - }, - }, +const mcp = { + servers: { + configured: { type: 'local', command: ['npx', 'server'] }, }, } -function renderManager(onUpdate: (content: Record) => Promise) { +function renderManager( + onSave: (serverId: string, server: McpServerConfig) => Promise, + onDelete: (serverId: string) => Promise, +) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return render( - + , ) } @@ -66,43 +66,61 @@ describe('McpManager', () => { }) it('lists servers reported by the running server that are not in mcp.servers', () => { - renderManager(vi.fn()) + renderManager(vi.fn(), vi.fn()) expect(screen.getByText('Configured')).toBeInTheDocument() expect(screen.getByText('Legacy server')).toBeInTheDocument() }) it('removes only the mcp.servers entry when deleting a configured server', async () => { - const onUpdate = vi.fn<(content: Record) => Promise>().mockResolvedValue(undefined) + const onSave = vi.fn().mockResolvedValue(undefined) + const onDelete = vi.fn().mockResolvedValue(undefined) const user = userEvent.setup() - renderManager(onUpdate) + renderManager(onSave, onDelete) await deleteServer(user, 'Configured') - await waitFor(() => expect(onUpdate).toHaveBeenCalledWith({ mcp: { servers: {} } })) + await waitFor(() => expect(onDelete).toHaveBeenCalledWith('configured')) expect(mockRemoveServer).not.toHaveBeenCalled() }) it('skips the config update for a server that is not in mcp.servers', async () => { - const onUpdate = vi.fn<(content: Record) => Promise>().mockResolvedValue(undefined) + const onSave = vi.fn().mockResolvedValue(undefined) + const onDelete = vi.fn().mockResolvedValue(undefined) const user = userEvent.setup() - renderManager(onUpdate) + renderManager(onSave, onDelete) await deleteServer(user, 'Legacy server') await waitFor(() => expect(mockRemoveServer).toHaveBeenCalledWith('legacy-server')) - expect(onUpdate).not.toHaveBeenCalled() + expect(onDelete).not.toHaveBeenCalled() }) it('surfaces the config save error when deleting a configured server', async () => { const message = 'Cannot remove mcp.servers.configured: defined in opencode.json, not in opencode.jsonc' - const onUpdate = vi.fn<(content: Record) => Promise>().mockRejectedValue(new FetchError(message, 409)) + const onDelete = vi.fn().mockRejectedValue(new FetchError(message, 409)) const user = userEvent.setup() - renderManager(onUpdate) + renderManager(vi.fn().mockResolvedValue(undefined), onDelete) await deleteServer(user, 'Configured') await waitFor(() => expect(mockToastError).toHaveBeenCalledWith(message)) expect(mockRemoveServer).not.toHaveBeenCalled() }) + + it('saves a new server through the config patch callback', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const user = userEvent.setup() + renderManager(onSave, vi.fn().mockResolvedValue(undefined)) + + await user.click(screen.getByRole('button', { name: /Add Server/i })) + await user.type(await screen.findByLabelText('Server ID'), 'filesystem') + await user.type(screen.getByLabelText('Command'), 'npx server-filesystem /tmp') + await user.click(screen.getByRole('button', { name: 'Add MCP Server' })) + + await waitFor(() => expect(onSave).toHaveBeenCalledWith('filesystem', expect.objectContaining({ + type: 'local', + command: ['npx', 'server-filesystem', '/tmp'], + }))) + }) }) diff --git a/frontend/src/components/settings/McpManager.tsx b/frontend/src/components/settings/McpManager.tsx index 04b72a7fc..821a4ff8e 100644 --- a/frontend/src/components/settings/McpManager.tsx +++ b/frontend/src/components/settings/McpManager.tsx @@ -13,26 +13,17 @@ import { invalidateConfigCaches } from '@/lib/queryInvalidation' import type { McpAuthStartResponse } from '@/api/mcp' import { mcpApi } from '@/api/mcp' import { mcpServersFromConfig, type McpServerConfig } from '@opencode-manager/shared/opencode' -import { settingsApi } from '@/api/settings' import { showToast } from '@/lib/toast' import { getOpenCodeApiErrorMessage } from '@/lib/opencode-errors' import { formatMcpServerName } from '@/lib/mcp' interface McpManagerProps { - config: { - content: Record - } | null - onUpdate: (content: Record) => Promise + mcp: unknown + onSave: (serverId: string, config: McpServerConfig) => Promise + onDelete: (serverId: string) => Promise } -function withoutMcpServer(mcp: unknown, serverId: string): Record { - const current = (mcp && typeof mcp === 'object' ? mcp : {}) as Record - const servers = { ...(current.servers as Record | undefined) } - delete servers[serverId] - return { ...current, servers } -} - -export function McpManager({ config, onUpdate }: McpManagerProps) { +export function McpManager({ mcp, onSave, onDelete }: McpManagerProps) { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [deleteConfirmServer, setDeleteConfirmServer] = useState<{ id: string; name: string } | null>(null) const [togglingServerId, setTogglingServerId] = useState(null) @@ -50,7 +41,7 @@ export function McpManager({ config, onUpdate }: McpManagerProps) { isRemovingAuth } = useMcpServers() - const mcpServers = useMemo(() => mcpServersFromConfig(config?.content?.mcp), [config]) + const mcpServers = useMemo(() => mcpServersFromConfig(mcp), [mcp]) const serverIds = useMemo( () => [...new Set([...Object.keys(mcpServers), ...Object.keys(mcpStatus ?? {})])], [mcpServers, mcpStatus], @@ -58,11 +49,8 @@ export function McpManager({ config, onUpdate }: McpManagerProps) { const deleteServerMutation = useMutation({ mutationFn: async (serverId: string) => { - if (config && mcpServers[serverId]) { - await onUpdate({ - ...config.content, - mcp: withoutMcpServer(config.content.mcp, serverId), - }) + if (mcpServers[serverId]) { + await onDelete(serverId) return } @@ -79,15 +67,7 @@ export function McpManager({ config, onUpdate }: McpManagerProps) { }) const addServer = async (serverId: string, serverConfig: McpServerConfig) => { - const latest = await settingsApi.getOpenCodeConfig() - const mcp = (latest.content.mcp as Record | undefined) ?? {} - await onUpdate({ - ...latest.content, - mcp: { - ...mcp, - servers: { ...(mcp.servers as Record | undefined), [serverId]: serverConfig }, - }, - }) + await onSave(serverId, serverConfig) } const isAnyOperationPending = deleteServerMutation.isPending || togglingServerId !== null @@ -161,14 +141,6 @@ export function McpManager({ config, onUpdate }: McpManagerProps) { return null } - if (!config) { - return ( -
-

No OpenCode configuration file found.

-
- ) - } - return (
{isAnyOperationPending && ( diff --git a/frontend/src/components/settings/OpenCodeConfigEditor.test.tsx b/frontend/src/components/settings/OpenCodeConfigEditor.test.tsx index 64462e6c4..04f3378de 100644 --- a/frontend/src/components/settings/OpenCodeConfigEditor.test.tsx +++ b/frontend/src/components/settings/OpenCodeConfigEditor.test.tsx @@ -155,9 +155,14 @@ describe('OpenCodeConfigEditor', () => { expect(scrollTopSets).toBeGreaterThanOrEqual(2) }) - it('lists schema validation issues as line jumps', async () => { + it('lists backend validation issues as line jumps', async () => { + const onUpdate = vi.fn().mockRejectedValue( + new FetchError('Invalid config data', 400, undefined, undefined, { + validationIssues: [{ path: 'theme', message: 'Expected string' }], + }), + ) + renderEditor({ onUpdate }) const user = userEvent.setup() - renderEditor() const textarea = screen.getByLabelText('Config content') as HTMLTextAreaElement setContent(textarea, '{\n "theme": 42\n}') await user.click(screen.getByRole('button', { name: 'Update' })) @@ -169,16 +174,22 @@ describe('OpenCodeConfigEditor', () => { expect(row).toHaveAttribute('data-line', '2') }) - it('does not call onUpdate when local validation fails', async () => { - const { onUpdate } = renderEditor() + it('sends content the browser cannot validate to the backend instead of rejecting it locally', async () => { + const onUpdate = vi.fn().mockRejectedValue( + new FetchError('Invalid config data', 400, undefined, undefined, { + validationIssues: [{ path: 'theme', message: 'Expected string' }], + }), + ) + renderEditor({ onUpdate }) const user = userEvent.setup() const textarea = screen.getByLabelText('Config content') as HTMLTextAreaElement - setContent(textarea, '{\n "theme": 42\n}') + const invalid = '{\n "theme": 42\n}' + setContent(textarea, invalid) await user.click(screen.getByRole('button', { name: 'Update' })) await waitFor(() => { - expect(screen.getByText(/Configuration validation failed/)).toBeInTheDocument() + expect(onUpdate).toHaveBeenCalledWith({ content: invalid, source: 'opencode.json', expectedRevision: 'rev-1' }) }) - expect(onUpdate).not.toHaveBeenCalled() + expect(await screen.findByText(/theme: Expected string/)).toBeInTheDocument() }) it('saves the raw text including comments', async () => { @@ -348,14 +359,19 @@ describe('OpenCodeConfigEditor', () => { expect(onUpdate).toHaveBeenCalledWith({ content: RAW, source: 'opencode.json', expectedRevision: 'rev-1' }) }) - it('resolves a validation issue under a dotted provider key to its line', async () => { + it('resolves a nested backend validation issue to its line', async () => { + const onUpdate = vi.fn().mockRejectedValue( + new FetchError('Invalid config data', 400, undefined, undefined, { + validationIssues: [{ path: 'agents.assistant.mode', message: 'Expected a known mode' }], + }), + ) + renderEditor({ onUpdate }) const user = userEvent.setup() - renderEditor() const textarea = screen.getByLabelText('Config content') as HTMLTextAreaElement - const dotted = '{\n "provider": {\n "api.example.com": {\n "key": 123\n }\n }\n}' - setContent(textarea, dotted) + const nested = '{\n "agents": {\n "assistant": {\n "mode": 42\n }\n }\n}' + setContent(textarea, nested) await user.click(screen.getByRole('button', { name: 'Update' })) - const issueButton = await screen.findByRole('button', { name: /api\.example\.com/i }) + const issueButton = await screen.findByRole('button', { name: /agents\.assistant\.mode/i }) expect(issueButton).toBeInTheDocument() await user.click(issueButton) const activeLine = document.body.querySelector('[data-active-line]') diff --git a/frontend/src/components/settings/OpenCodeConfigEditor.tsx b/frontend/src/components/settings/OpenCodeConfigEditor.tsx index 97816e6b7..a5535671b 100644 --- a/frontend/src/components/settings/OpenCodeConfigEditor.tsx +++ b/frontend/src/components/settings/OpenCodeConfigEditor.tsx @@ -12,7 +12,6 @@ import { useMobile } from '@/hooks/useMobile' import { useFindInText } from '@/lib/useFindInText' import { parseJsonc, parseJsoncErrorLine, resolveJsoncIssueLine } from '@/lib/jsonc' import { FetchError } from '@/api/fetchWrapper' -import { OpenCodeConfigSchema } from '@opencode-manager/shared' import { downloadOpenCodeConfigSource, getOpenCodeConfigSources, @@ -20,6 +19,7 @@ import { isOpenCodeConfigSourceName, } from '@/api/types/settings' import type { OpenCodeConfigFile, OpenCodeConfigSourceFile, OpenCodeConfigSourceName } from '@/api/types/settings' +import type { OpenCodeConfigValidationIssue } from '@opencode-manager/shared' type ValidationIssue = { path: string @@ -122,14 +122,11 @@ export function OpenCodeConfigEditor({ const getIssueText = (issue: ValidationIssue) => `${issue.path}: ${issue.message}` const resolveIssues = ( - issues: Array<{ path: PropertyKey[] | string; message: string }>, + issues: OpenCodeConfigValidationIssue[], ): ValidationIssue[] => issues.map((issue) => { const path = issue.path - const isStructured = Array.isArray(path) - const displaySegments = isStructured - ? path.map(String) - : String(path).split('.').filter((segment) => segment.length > 0) + const displaySegments = path.split('.').filter((segment) => segment.length > 0) const displayPath = displaySegments.length > 0 ? displaySegments.join('.') : 'root' return { path: displayPath, @@ -143,14 +140,7 @@ export function OpenCodeConfigEditor({ try { resetErrors() - const parsedConfig = parseJsonc>(editConfigContent) - const validationResult = OpenCodeConfigSchema.safeParse(parsedConfig) - if (!validationResult.success) { - const issues = resolveIssues(validationResult.error.issues) - setValidationIssues(issues) - setEditError(`Configuration validation failed: ${issues.map(getIssueText).join('; ')}`) - return - } + parseJsonc>(editConfigContent) setIsSaving(true) await onUpdate({ content: editConfigContent, source: draftSource.name, expectedRevision: draftRevision }) diff --git a/frontend/src/components/settings/OpenCodeConfigManager.test.tsx b/frontend/src/components/settings/OpenCodeConfigManager.test.tsx index 844eff83d..a37b32d5c 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.test.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.test.tsx @@ -3,12 +3,15 @@ import { render, screen, waitFor, fireEvent, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { OpenCodeConfigManager } from './OpenCodeConfigManager' +import { FetchError } from '@/api/fetchWrapper' import type { OpenCodeConfigFile } from '@/api/types/settings' import { makeOpenCodeConfigFile, makeOpenCodeConfigSource } from '@/test/fixtures/opencode-config' const { mockGetOpenCodeConfig, mockUpdateOpenCodeConfig, + mockGetManagedOpenCodeConfig, + mockPatchOpenCodeConfig, mockRestartOpenCodeServer, mockGetActiveOpenCodeSessions, mockGetOpenCodeImportStatus, @@ -19,6 +22,8 @@ const { } = vi.hoisted(() => ({ mockGetOpenCodeConfig: vi.fn(), mockUpdateOpenCodeConfig: vi.fn(), + mockGetManagedOpenCodeConfig: vi.fn(), + mockPatchOpenCodeConfig: vi.fn(), mockRestartOpenCodeServer: vi.fn(), mockGetActiveOpenCodeSessions: vi.fn(), mockGetOpenCodeImportStatus: vi.fn(), @@ -65,17 +70,39 @@ vi.mock('@/api/settings', () => ({ settingsApi: { getOpenCodeConfig: mockGetOpenCodeConfig, updateOpenCodeConfig: mockUpdateOpenCodeConfig, + getManagedOpenCodeConfig: mockGetManagedOpenCodeConfig, + patchOpenCodeConfig: mockPatchOpenCodeConfig, restartOpenCodeServer: mockRestartOpenCodeServer, getActiveOpenCodeSessions: mockGetActiveOpenCodeSessions, getOpenCodeImportStatus: mockGetOpenCodeImportStatus, listManagedSkills: mockListManagedSkills, listOpenCodeDirectoryFiles: mockListOpenCodeDirectoryFiles, getAgentsMd: mockGetAgentsMd, + discoverOpenCodeModels: vi.fn().mockResolvedValue({ models: [], cached: false }), syncOpenCodeImport: vi.fn(), upgradeOpenCode: vi.fn(), }, })) +vi.mock('@/components/model/ModelCombobox', () => ({ ModelCombobox: () => null })) + +vi.mock('./AgentDialog', () => ({ + AgentDialog: ({ open, onSubmit, editingAgent }: { + open: boolean + onSubmit: (name: string, agent: Record) => void + editingAgent?: { name: string } | null + }) => open ? ( +
+ + +
+ ) : null, +})) + const defaultContent = { provider: { openai: { @@ -87,6 +114,17 @@ const defaultContent = { }, } +const defaultManagedInfo = { + providers: { + openai: { + name: 'OpenAI', + models: { + 'gpt-4o': { name: 'GPT-4o', modelID: 'gpt-4o' }, + }, + }, + }, +} + const defaultConfig = makeOpenCodeConfigFile({ path: '/workspace/.opencode/opencode.json', rawContent: JSON.stringify(defaultContent, null, 2), @@ -108,6 +146,8 @@ describe('OpenCodeConfigManager', () => { vi.clearAllMocks() healthState.data = { opencode: 'healthy', opencodeRestartPending: false } mockGetOpenCodeConfig.mockResolvedValue(defaultConfig) + mockGetManagedOpenCodeConfig.mockResolvedValue({ info: defaultManagedInfo }) + mockPatchOpenCodeConfig.mockResolvedValue({ ...defaultConfig, revision: 'rev-2' }) mockGetOpenCodeImportStatus.mockResolvedValue({}) mockGetAgentsMd.mockResolvedValue({ content: '# Agent Instructions' }) mockListManagedSkills.mockResolvedValue([]) @@ -146,7 +186,7 @@ describe('OpenCodeConfigManager', () => { }) it('optimistic delete saves without eager restart modal', async () => { - mockUpdateOpenCodeConfig.mockResolvedValueOnce({ ...defaultConfig, restartRequired: true }) + mockPatchOpenCodeConfig.mockResolvedValueOnce({ ...defaultConfig, restartRequired: true }) const user = userEvent.setup() renderWithQuery() @@ -158,9 +198,9 @@ describe('OpenCodeConfigManager', () => { await user.click(screen.getByLabelText('Actions for GPT-4o')) await user.click(screen.getByText('Delete')) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1) - const [payload] = mockUpdateOpenCodeConfig.mock.calls[0] - expect(payload.content.provider.openai.models).not.toHaveProperty('gpt-4o') + expect(mockPatchOpenCodeConfig).toHaveBeenCalledTimes(1) + const [payload] = mockPatchOpenCodeConfig.mock.calls[0] + expect(payload.patch.providers.openai.models).toEqual({ 'gpt-4o': null }) expect(screen.queryByText('Restart OpenCode Server?')).not.toBeInTheDocument() }) @@ -181,7 +221,7 @@ describe('OpenCodeConfigManager', () => { }) it('rollback on failure', async () => { - mockUpdateOpenCodeConfig.mockRejectedValueOnce(new Error('boom')) + mockPatchOpenCodeConfig.mockRejectedValueOnce(new Error('boom')) const user = userEvent.setup() renderWithQuery() @@ -193,7 +233,7 @@ describe('OpenCodeConfigManager', () => { await user.click(screen.getByLabelText('Actions for GPT-4o')) await user.click(screen.getByText('Delete')) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1) + expect(mockPatchOpenCodeConfig).toHaveBeenCalledTimes(1) const { showToast } = await import('@/lib/toast') await vi.waitFor(() => { @@ -360,7 +400,7 @@ describe('OpenCodeConfigManager', () => { }) it('sends the expected revision for structured updates', async () => { - mockUpdateOpenCodeConfig.mockResolvedValueOnce({ ...defaultConfig, restartRequired: true }) + mockPatchOpenCodeConfig.mockResolvedValueOnce({ ...defaultConfig, restartRequired: true }) const user = userEvent.setup() renderWithQuery() @@ -372,22 +412,17 @@ describe('OpenCodeConfigManager', () => { await user.click(screen.getByLabelText('Actions for GPT-4o')) await user.click(screen.getByText('Delete')) - await waitFor(() => expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1)) - const [payload] = mockUpdateOpenCodeConfig.mock.calls[0] + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledTimes(1)) + const [payload] = mockPatchOpenCodeConfig.mock.calls[0] expect(payload.expectedRevision).toBe('rev-1') expect(payload.source).toBeUndefined() - expect(payload.content.provider.openai.models).not.toHaveProperty('gpt-4o') + expect(payload.patch.providers.openai.models).toEqual({ 'gpt-4o': null }) }) it('resolves the add-server expected revision from the query cache', async () => { const cachedConfig = { ...defaultConfig, revision: 'rev-A' } - const dialogFetchedConfig = { ...defaultConfig, revision: 'rev-B' } - let configRequests = 0 - mockGetOpenCodeConfig.mockImplementation(() => { - configRequests += 1 - return Promise.resolve(configRequests === 1 ? cachedConfig : dialogFetchedConfig) - }) - mockUpdateOpenCodeConfig.mockResolvedValue({ ...cachedConfig, restartRequired: true }) + mockGetOpenCodeConfig.mockResolvedValue(cachedConfig) + mockPatchOpenCodeConfig.mockResolvedValue({ ...cachedConfig, restartRequired: true }) const user = userEvent.setup() renderWithQuery() @@ -399,46 +434,53 @@ describe('OpenCodeConfigManager', () => { await user.type(screen.getByLabelText('Command'), 'npx server-filesystem /tmp') await user.click(screen.getByRole('button', { name: 'Add MCP Server' })) - await waitFor(() => expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1)) - const [payload] = mockUpdateOpenCodeConfig.mock.calls[0] + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledTimes(1)) + const [payload] = mockPatchOpenCodeConfig.mock.calls[0] expect(payload.expectedRevision).toBe('rev-A') + expect(payload.patch.mcp.servers.filesystem).toEqual(expect.objectContaining({ + type: 'local', + command: ['npx', 'server-filesystem', '/tmp'], + })) }) - it('sends the revision from the previous save on the next structured save', async () => { - const twoModelsContent = { - provider: { - openai: { - name: 'OpenAI', - models: { - 'gpt-4o': { name: 'GPT-4o' }, - 'gpt-3.5': { name: 'GPT-3.5' }, - }, - }, - }, - } - const twoModelsConfig = makeOpenCodeConfigFile({ - path: '/workspace/.opencode/opencode.json', - rawContent: JSON.stringify(twoModelsContent, null, 2), - content: twoModelsContent, + it('deletes a configured MCP server through an mcp.servers patch', async () => { + mockGetManagedOpenCodeConfig.mockResolvedValue({ + info: { mcp: { servers: { filesystem: { type: 'local', command: ['npx', 'server'] } } } }, }) - mockGetOpenCodeConfig.mockResolvedValue(twoModelsConfig) + mockPatchOpenCodeConfig.mockResolvedValue({ ...defaultConfig, revision: 'rev-2' }) + + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('opencode.json') + await user.click(screen.getByRole('button', { name: /MCP Servers/i })) + await user.click(await screen.findByLabelText('Actions for Filesystem')) + await user.click(screen.getByText('Delete Server')) + await user.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledWith({ + patch: { mcp: { servers: { filesystem: null } } }, + expectedRevision: 'rev-1', + })) + }) - const afterFirstDelete = { - ...twoModelsConfig, - content: { - provider: { + it('sends the revision from the previous save on the next structured save', async () => { + mockGetManagedOpenCodeConfig.mockResolvedValue({ + info: { + providers: { openai: { name: 'OpenAI', models: { + 'gpt-4o': { name: 'GPT-4o' }, 'gpt-3.5': { name: 'GPT-3.5' }, }, }, }, }, - revision: 'rev-2', - } - const afterSecondDelete = { ...afterFirstDelete, revision: 'rev-3' } - mockUpdateOpenCodeConfig + }) + const afterFirstDelete = { ...defaultConfig, revision: 'rev-2' } + const afterSecondDelete = { ...defaultConfig, revision: 'rev-3' } + mockPatchOpenCodeConfig .mockResolvedValueOnce(afterFirstDelete) .mockResolvedValueOnce(afterSecondDelete) @@ -450,13 +492,131 @@ describe('OpenCodeConfigManager', () => { await user.click(screen.getByLabelText('Actions for GPT-4o')) await user.click(screen.getByText('Delete')) - await waitFor(() => expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1)) - expect(mockUpdateOpenCodeConfig.mock.calls[0][0].expectedRevision).toBe('rev-1') + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledTimes(1)) + expect(mockPatchOpenCodeConfig.mock.calls[0][0].expectedRevision).toBe('rev-1') await user.click(screen.getByLabelText('Actions for GPT-3.5')) await user.click(screen.getByText('Delete')) - await waitFor(() => expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(2)) - expect(mockUpdateOpenCodeConfig.mock.calls[1][0].expectedRevision).toBe('rev-2') + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledTimes(2)) + expect(mockPatchOpenCodeConfig.mock.calls[1][0].expectedRevision).toBe('rev-2') + }) + + it('adds a model to a new API provider through a providers patch', async () => { + mockGetManagedOpenCodeConfig.mockResolvedValue({ info: {} }) + mockPatchOpenCodeConfig.mockResolvedValue({ ...defaultConfig, revision: 'rev-2' }) + + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('opencode.json') + await user.click(screen.getByRole('button', { name: /Models/i })) + await user.click(screen.getByRole('button', { name: /Add Model/i })) + + fireEvent.change(await screen.findByPlaceholderText('e.g., my-provider'), { target: { value: 'my-provider' } }) + fireEvent.change(screen.getByPlaceholderText('e.g., My Provider'), { target: { value: 'My Provider' } }) + fireEvent.change(screen.getByPlaceholderText('e.g., https://api.openai.com/v1'), { target: { value: 'https://api.example.com/v1' } }) + fireEvent.change(screen.getByPlaceholderText('e.g., qwen-3.5-27b'), { target: { value: 'gpt-5' } }) + fireEvent.change(screen.getByPlaceholderText('e.g., MiniMax-M2.7'), { target: { value: 'gpt-5-model' } }) + fireEvent.change(screen.getByPlaceholderText('e.g., Qwen3.5-27B'), { target: { value: 'GPT-5' } }) + + const createButton = screen.getByRole('button', { name: /create/i }) + await waitFor(() => expect(createButton).not.toBeDisabled()) + await user.click(createButton) + + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledWith({ + patch: { + providers: { + 'my-provider': { + name: 'My Provider', + settings: { baseURL: 'https://api.example.com/v1' }, + models: { + 'gpt-5': expect.objectContaining({ modelID: 'gpt-5-model', name: 'GPT-5' }), + }, + }, + }, + }, + expectedRevision: 'rev-1', + })) + }) + + it('saves an edited model under providers when the managed response converted a v1 entry', async () => { + mockGetManagedOpenCodeConfig.mockResolvedValue({ + info: { + providers: { + openai: { + name: 'OpenAI', + models: { 'gpt-4o': { name: 'GPT-4o', modelID: 'gpt-4o' } }, + }, + }, + }, + }) + mockPatchOpenCodeConfig.mockResolvedValue({ ...defaultConfig, revision: 'rev-2' }) + + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('opencode.json') + await user.click(screen.getByRole('button', { name: /Models/i })) + await user.click(await screen.findByText('GPT-4o')) + + fireEvent.change(await screen.findByPlaceholderText('e.g., Qwen3.5-27B'), { target: { value: 'GPT-4o Updated' } }) + await user.click(screen.getByRole('button', { name: /update/i })) + + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledWith({ + patch: { + providers: { + openai: { + name: 'OpenAI', + models: { + 'gpt-4o': expect.objectContaining({ name: 'GPT-4o Updated', modelID: 'gpt-4o' }), + }, + }, + }, + }, + expectedRevision: 'rev-1', + })) + }) + + it('keeps a converted provider and its sibling models when deleting one model', async () => { + mockGetManagedOpenCodeConfig.mockResolvedValue({ + info: { + providers: { + p: { + name: 'Provider P', + settings: { baseURL: 'http://x' }, + models: { + a: { name: 'Model A' }, + b: { name: 'Model B' }, + }, + }, + }, + }, + }) + mockPatchOpenCodeConfig.mockResolvedValue({ ...defaultConfig, revision: 'rev-2' }) + + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('opencode.json') + await user.click(screen.getByRole('button', { name: /Models/i })) + await user.click(await screen.findByLabelText('Actions for Model A')) + await user.click(screen.getByText('Delete')) + + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledWith({ + patch: { + providers: { + p: { + name: 'Provider P', + settings: { baseURL: 'http://x' }, + models: { + a: null, + b: { name: 'Model B' }, + }, + }, + }, + }, + expectedRevision: 'rev-1', + })) }) it('reports a comment-only raw save as applied without a restart', async () => { @@ -484,7 +644,7 @@ describe('OpenCodeConfigManager', () => { }) it('asks for a restart after a semantic structured change', async () => { - mockUpdateOpenCodeConfig.mockResolvedValueOnce({ ...defaultConfig, restartRequired: true }) + mockPatchOpenCodeConfig.mockResolvedValueOnce({ ...defaultConfig, restartRequired: true }) const user = userEvent.setup() renderWithQuery() @@ -643,4 +803,164 @@ describe('OpenCodeConfigManager', () => { expect(notice).toHaveTextContent('Saves apply only to opencode.jsonc') expect(notice).toHaveTextContent('For simpler configuration, consolidate the settings you need into one file, then remove redundant files after verifying the result.') }) + + it('lists agents and commands from the managed config query', async () => { + mockGetManagedOpenCodeConfig.mockResolvedValue({ + info: { + agents: { reviewer: { system: 'Review code', description: 'Reviews code', mode: 'subagent' } }, + commands: { deploy: { template: 'Deploy it', description: 'Deploys the app' } }, + }, + }) + + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('opencode.json') + await user.click(screen.getByRole('button', { name: /Commands/i })) + expect(await screen.findByText('deploy')).toBeInTheDocument() + + const agentsButton = screen.getAllByRole('button', { name: /Agents/i }).find(button => button.textContent?.startsWith('Agents')) + expect(agentsButton).toBeDefined() + await user.click(agentsButton!) + expect(await screen.findByText('reviewer')).toBeInTheDocument() + }) + + it('shows the unavailable notice and keeps the raw editor reachable when OpenCode is down', async () => { + mockGetManagedOpenCodeConfig.mockRejectedValue(new FetchError('OpenCode server unavailable', 503)) + + const user = userEvent.setup() + const { container } = renderWithQuery() + + await screen.findByText('opencode.json') + await user.click(screen.getByRole('button', { name: /Commands/i })) + expect(await screen.findByText(/OpenCode is not running, so the configured commands cannot be listed/)).toBeInTheDocument() + + const agentsButton = screen.getAllByRole('button', { name: /Agents/i }).find(button => button.textContent?.startsWith('Agents')) + expect(agentsButton).toBeDefined() + await user.click(agentsButton!) + expect(await screen.findByText(/OpenCode is not running, so the configured agents cannot be listed/)).toBeInTheDocument() + + const editIcon = container.querySelector('.lucide-square-pen') as SVGElement + await user.click(editIcon.closest('button') as HTMLButtonElement) + expect(await screen.findByLabelText('Config content')).toBeInTheDocument() + }) + + it('shows an error notice instead of empty managed sections when the managed config request fails', async () => { + mockGetManagedOpenCodeConfig.mockRejectedValue(new FetchError('Bad Gateway', 502)) + + renderWithQuery() + + await screen.findByText('opencode.json') + expect(await screen.findAllByText('OpenCode configuration unavailable')).toHaveLength(4) + expect(screen.getAllByText('Bad Gateway')).toHaveLength(4) + + const commandsHeader = screen.getByRole('button', { name: /^Commands/ }) + expect(commandsHeader).toHaveTextContent('Unavailable') + expect(commandsHeader).not.toHaveTextContent('0 configured') + expect(screen.queryByText('No commands configured')).not.toBeInTheDocument() + expect(screen.queryByText('No agents configured')).not.toBeInTheDocument() + expect(screen.queryByText('No models configured')).not.toBeInTheDocument() + }) + + it('shows a loading state instead of empty managed sections while the managed config request is pending', async () => { + mockGetManagedOpenCodeConfig.mockReturnValue(new Promise(() => {})) + + renderWithQuery() + + await screen.findByText('opencode.json') + expect(await screen.findAllByText('Loading configuration...')).toHaveLength(4) + expect(screen.getByRole('button', { name: /^Commands/ })).toHaveTextContent('Loading...') + expect(screen.queryByText('No commands configured')).not.toBeInTheDocument() + }) + + it('saves an agent with an entity patch through the managed config endpoint', async () => { + mockGetManagedOpenCodeConfig.mockResolvedValue({ + info: { agents: { reviewer: { system: 'Review code', mode: 'subagent' } } }, + }) + + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('opencode.json') + const agentsButton = screen.getAllByRole('button', { name: /Agents/i }).find(button => button.textContent?.startsWith('Agents')) + expect(agentsButton).toBeDefined() + await user.click(agentsButton!) + await user.click(await screen.findByText('reviewer')) + await user.click(await screen.findByRole('button', { name: 'Submit Agent' })) + + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledWith({ + patch: { agents: { reviewer: { system: 'Updated', mode: 'subagent' } } }, + expectedRevision: 'rev-1', + })) + }) + + it('deletes the previous agent name in the same patch when renaming', async () => { + mockGetManagedOpenCodeConfig.mockResolvedValue({ + info: { agents: { reviewer: { system: 'Review code', mode: 'subagent' } } }, + }) + + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('opencode.json') + const agentsButton = screen.getAllByRole('button', { name: /Agents/i }).find(button => button.textContent?.startsWith('Agents')) + expect(agentsButton).toBeDefined() + await user.click(agentsButton!) + await user.click(await screen.findByText('reviewer')) + await user.click(await screen.findByRole('button', { name: 'Submit Renamed Agent' })) + + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledWith({ + patch: { + agents: { + 'renamed-agent': { system: 'Renamed', mode: 'subagent' }, + reviewer: null, + }, + }, + expectedRevision: 'rev-1', + })) + }) + + it('sends null for cleared command fields and a disabled subagent', async () => { + mockGetManagedOpenCodeConfig.mockResolvedValue({ + info: { + commands: { + review: { + template: 'Review this code', + description: 'Reviews changes', + agent: 'reviewer', + model: 'openai/gpt-5', + subagent: true, + }, + }, + }, + }) + + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('opencode.json') + await user.click(screen.getByRole('button', { name: /Commands/i })) + await user.click(await screen.findByText('review')) + + await user.clear(screen.getByLabelText('Description')) + await user.clear(screen.getByLabelText('Agent (optional)')) + await user.clear(screen.getByLabelText('Model (optional)')) + await user.click(screen.getByRole('switch')) + await user.click(screen.getByRole('button', { name: 'Update' })) + + await waitFor(() => expect(mockPatchOpenCodeConfig).toHaveBeenCalledWith({ + patch: { + commands: { + review: { + template: 'Review this code', + description: null, + agent: null, + model: null, + subagent: null, + }, + }, + }, + expectedRevision: 'rev-1', + })) + }) }) diff --git a/frontend/src/components/settings/OpenCodeConfigManager.tsx b/frontend/src/components/settings/OpenCodeConfigManager.tsx index e02d4852f..5c234a022 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.tsx @@ -11,7 +11,7 @@ import { AgentsEditor } from './AgentsEditor' import { AgentsMdEditor } from './AgentsMdEditor' import { McpManager } from './McpManager' import { SkillsEditor } from './SkillsEditor' -import { OpenCodeModelsEditor, type ConfigProvider } from './OpenCodeModelsEditor' +import { OpenCodeModelsEditor } from './OpenCodeModelsEditor' import { scrollSectionIntoView } from '@/lib/settingsScroll' import { VersionSelectDialog } from './VersionSelectDialog' import { settingsApi } from '@/api/settings' @@ -19,41 +19,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useServerHealth } from '@/hooks/useServerHealth' import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' import { useOpenCodeConfigFile, OPEN_CODE_CONFIG_QUERY_KEY } from '@/hooks/useOpenCodeConfigFile' +import { useOpenCodeManagedConfig } from '@/hooks/useOpenCodeManagedConfig' import { useOpenCodeApplyFeedback } from '@/hooks/useOpenCodeApplyFeedback' import { showToast } from '@/lib/toast' -import { invalidateConfigCaches } from '@/lib/queryInvalidation' +import { invalidateConfigCaches, OPEN_CODE_MANAGED_CONFIG_QUERY_KEY } from '@/lib/queryInvalidation' import { getOpenCodeApiErrorMessage } from '@/lib/opencode-errors' import { mcpServersFromConfig } from '@opencode-manager/shared/opencode' import { FetchError } from '@/api/fetchWrapper' import { getPreferredOpenCodeConfigSource, downloadOpenCodeConfigSource } from '@/api/types/settings' -import type { OpenCodeConfigFile, OpenCodeConfigSaveResponse, OpenCodeImportStatus } from '@/api/types/settings' - -interface Command { - template: string - description?: string - agent?: string - model?: string - subtask?: boolean - topP?: number -} - -interface Agent { - prompt?: string - description?: string - mode?: 'subagent' | 'primary' | 'all' - temperature?: number - topP?: number - top_p?: number - model?: string - tools?: Record - permission?: { - edit?: 'ask' | 'allow' | 'deny' - bash?: 'ask' | 'allow' | 'deny' | Record - webfetch?: 'ask' | 'allow' | 'deny' - } - disable?: boolean - [key: string]: unknown -} +import type { OpenCodeConfigFile, OpenCodeConfigSaveResponse, OpenCodeImportStatus, OpenCodeAgentPatch, OpenCodeCommandPatch } from '@/api/types/settings' type ConfigSectionKey = 'agentsMd' | 'commands' | 'agents' | 'skills' | 'mcp' | 'models' | 'hostImport' @@ -66,6 +40,38 @@ const SECTION_META_CLASS = 'text-xs text-muted-foreground' const getConfigFileName = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path +interface ManagedConfigSectionFallbackProps { + isLoading: boolean + unavailableMessage: string | null + errorMessage: string | null +} + +function ManagedConfigSectionFallback({ isLoading, unavailableMessage, errorMessage }: ManagedConfigSectionFallbackProps) { + if (isLoading) { + return ( +

+ + Loading configuration... +

+ ) + } + + if (unavailableMessage) { + return

{unavailableMessage}

+ } + + if (errorMessage) { + return ( +
+

OpenCode configuration unavailable

+

{errorMessage}

+
+ ) + } + + return null +} + export function OpenCodeConfigManager() { const sectionIdPrefix = useId() const agentsMdContentId = `${sectionIdPrefix}-agents-md` @@ -108,6 +114,16 @@ export function OpenCodeConfigManager() { const hostImportRef = useRef(null) const { data: config, isLoading } = useOpenCodeConfigFile() + const { + data: managedConfig, + error: managedConfigError, + isLoading: isManagedConfigLoading, + } = useOpenCodeManagedConfig() + const managedAgents = managedConfig?.info.agents ?? {} + const managedCommands = managedConfig?.info.commands ?? {} + const managedProviders = managedConfig?.info.providers ?? {} + const managedMcpServers = mcpServersFromConfig(managedConfig?.info.mcp) + const isManagedConfigUnavailable = managedConfigError instanceof FetchError && managedConfigError.statusCode === 503 const { data: managedSkills = [] } = useQuery({ queryKey: ['managed-skills'], @@ -159,6 +175,28 @@ export function OpenCodeConfigManager() { return getApiErrorMessage(error, 'Failed to import existing OpenCode host data') } + const isManagedConfigPending = isManagedConfigLoading || Boolean(managedConfigError) + const managedConfigErrorMessage = managedConfigError && !isManagedConfigUnavailable + ? getApiErrorMessage(managedConfigError, 'Failed to load the OpenCode configuration') + : null + + const getManagedConfigCountLabel = (count: number): string => { + if (isManagedConfigLoading) return 'Loading...' + if (managedConfigError) return 'Unavailable' + return `${count} configured` + } + + const renderManagedConfigSection = (unavailableMessage: string, content: React.ReactNode) => + isManagedConfigPending ? ( + + ) : ( + content + ) + const applyOpenCodeConfigSave = (result: OpenCodeConfigSaveResponse) => { queryClient.setQueryData(OPEN_CODE_CONFIG_QUERY_KEY, result) applyOpenCodeSaveFeedback({ @@ -167,23 +205,38 @@ export function OpenCodeConfigManager() { }) } - const updateConfigContent = async (newContent: Record) => { + const patchManagedConfig = async (patch: Record) => { const expectedRevision = queryClient.getQueryData( OPEN_CODE_CONFIG_QUERY_KEY, )?.revision - const result = await settingsApi.updateOpenCodeConfig({ - content: newContent, - expectedRevision, - }) + const result = await settingsApi.patchOpenCodeConfig({ patch, expectedRevision }) applyOpenCodeConfigSave(result) + queryClient.invalidateQueries({ queryKey: OPEN_CODE_MANAGED_CONFIG_QUERY_KEY }) } - const updateConfigContentSafely = (newContent: Record) => { - void updateConfigContent(newContent).catch((error) => { + const patchManagedConfigSafely = (patch: Record) => { + void patchManagedConfig(patch).catch((error) => { showToast.error(getApiErrorMessage(error, 'Failed to update config')) }) } + const saveManagedEntity = ( + key: 'agents' | 'commands', + name: string, + value: OpenCodeAgentPatch | OpenCodeCommandPatch, + previousName?: string, + ) => { + const entries: Record = { [name]: value } + if (previousName && previousName !== name) { + entries[previousName] = null + } + patchManagedConfigSafely({ [key]: entries }) + } + + const deleteManagedEntity = (key: 'agents' | 'commands', name: string) => { + patchManagedConfigSafely({ [key]: { [name]: null } }) + } + const downloadConfig = (config: OpenCodeConfigFile) => { const preferredSource = getPreferredOpenCodeConfigSource(config) if (preferredSource) { @@ -365,7 +418,7 @@ export function OpenCodeConfigManager() {

Commands

- {Object.keys((config.content.command as Record | undefined) ?? {}).length + directoryCommands.length} configured + {getManagedConfigCountLabel(Object.keys(managedCommands).length + directoryCommands.length)}
@@ -374,16 +427,15 @@ export function OpenCodeConfigManager() { id={commandsContentId} className={cn(SECTION_CONTENT_CLASS, expandedSections.commands ? 'block' : 'hidden')} > - | undefined) ?? {}} - directoryCommands={directoryCommands} - onChange={(commands) => { - updateConfigContentSafely({ - ...config.content, - command: commands - }) - }} - /> + {renderManagedConfigSection( + 'OpenCode is not running, so the configured commands cannot be listed. Use Edit to change the config file directly.', + saveManagedEntity('commands', name, command, previousName)} + onDelete={(name) => deleteManagedEntity('commands', name)} + />, + )}
@@ -399,7 +451,7 @@ export function OpenCodeConfigManager() {

Agents

- {Object.keys((config.content.agent as Record | undefined) ?? {}).length + directoryAgents.length} configured + {getManagedConfigCountLabel(Object.keys(managedAgents).length + directoryAgents.length)}
@@ -408,16 +460,15 @@ export function OpenCodeConfigManager() { id={agentsContentId} className={cn(SECTION_CONTENT_CLASS, expandedSections.agents ? 'block' : 'hidden')} > - | undefined) ?? {}} - directoryAgents={directoryAgents} - onChange={(agents) => { - updateConfigContentSafely({ - ...config.content, - agent: agents - }) - }} - /> + {renderManagedConfigSection( + 'OpenCode is not running, so the configured agents cannot be listed. Use Edit to change the config file directly.', + saveManagedEntity('agents', name, agent, previousName)} + onDelete={(name) => deleteManagedEntity('agents', name)} + />, + )}
@@ -460,7 +511,7 @@ export function OpenCodeConfigManager() {

MCP Servers

- {Object.keys(mcpServersFromConfig(config.content.mcp)).length} configured + {getManagedConfigCountLabel(Object.keys(managedMcpServers).length)}
@@ -469,10 +520,14 @@ export function OpenCodeConfigManager() { id={mcpContentId} className={cn(SECTION_CONTENT_CLASS, expandedSections.mcp ? 'block' : 'hidden')} > - + {renderManagedConfigSection( + 'OpenCode is not running, so the configured MCP servers cannot be listed. Use Edit to change the config file directly.', + patchManagedConfig({ mcp: { servers: { [serverId]: server } } })} + onDelete={(serverId) => patchManagedConfig({ mcp: { servers: { [serverId]: null } } })} + />, + )} @@ -488,14 +543,12 @@ export function OpenCodeConfigManager() {

Models

- {(() => { - const provider = config.content.provider as Record | undefined - if (!provider) return 0 - return Object.values(provider).reduce((acc, p) => { - const models = (p as { models?: Record })?.models - return acc + (models ? Object.keys(models).length : 0) - }, 0) - })()} configured + {getManagedConfigCountLabel( + Object.values(managedProviders).reduce( + (acc, provider) => acc + Object.keys(provider.models ?? {}).length, + 0, + ), + )}
@@ -504,15 +557,13 @@ export function OpenCodeConfigManager() { id={modelsContentId} className={cn(SECTION_CONTENT_CLASS, expandedSections.models ? 'block' : 'hidden')} > - | undefined) ?? {}} - onChange={(providers) => { - updateConfigContentSafely({ - ...config.content, - provider: providers - }) - }} - /> + {renderManagedConfigSection( + 'OpenCode is not running, so the configured models cannot be listed. Use Edit to change the config file directly.', + patchManagedConfigSafely({ providers })} + />, + )} diff --git a/frontend/src/components/settings/OpenCodeModelDialog.tsx b/frontend/src/components/settings/OpenCodeModelDialog.tsx index 7583e622a..708946bcc 100644 --- a/frontend/src/components/settings/OpenCodeModelDialog.tsx +++ b/frontend/src/components/settings/OpenCodeModelDialog.tsx @@ -13,70 +13,96 @@ import { Combobox } from '@/components/ui/combobox' import { Label } from '@/components/ui/label' import { RefreshCw, Loader2 } from 'lucide-react' import { settingsApi } from '@/api/settings' -import type { ModelConfig, ProviderConfig } from '@/api/types/settings' - -type ConfigModel = Partial & { - limit?: { - context?: number - input?: number - output?: number - } -} & Record - -type ConfigProvider = Omit, 'models' | 'env'> & { - api?: string - npm?: string - env?: string[] - models?: Record -} & Record +import { isRecord } from '@opencode-manager/shared/utils' +import type { OpenCodeProviderConfig, OpenCodeProviderModelConfig } from '@opencode-manager/shared/opencode' +import type { OpenCodeProviderModelPatch } from '@/api/types/settings' const handledModelKeys = new Set([ - 'id', - 'providerID', - 'api', + 'modelID', 'name', 'family', + 'releaseDate', + 'package', + 'settings', + 'headers', 'capabilities', 'cost', - 'limit', - 'status', - 'options', - 'headers', - 'release_date', 'variants', + 'limit', ]) -function parseJsonObject(value: string): Record | null { +type JsonObject = Record + +function parseJson(value: string): unknown { try { - const parsed = JSON.parse(value) as unknown - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record - } - return null + return JSON.parse(value) as unknown } catch { return null } } +function parseJsonObject(value: string): JsonObject | null { + const parsed = parseJson(value) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as JsonObject + } + return null +} + +function parseJsonContainer(value: string): JsonObject | unknown[] | null { + const parsed = parseJson(value) + if (Array.isArray(parsed)) return parsed + if (parsed && typeof parsed === 'object') return parsed as JsonObject + return null +} + +function parseJsonArray(value: string): unknown[] | null { + const parsed = parseJson(value) + return Array.isArray(parsed) ? parsed : null +} + function stringifyJson(value: unknown): string { - if (!value || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record).length === 0)) { + if (!value || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as JsonObject).length === 0)) { return '' } return JSON.stringify(value, null, 2) } -function parseOptionalJsonField(value: string): Record | undefined { +function parseOptionalJsonField(value: string): JsonObject | undefined { if (!value.trim()) return undefined return parseJsonObject(value) ?? undefined } +function parseOptionalJsonContainerField(value: string): JsonObject | unknown[] | undefined { + if (!value.trim()) return undefined + return parseJsonContainer(value) ?? undefined +} + +function parseOptionalJsonArrayField(value: string): unknown[] | undefined { + if (!value.trim()) return undefined + return parseJsonArray(value) ?? undefined +} + function parseOptionalNumber(value: string): number | undefined { if (!value.trim()) return undefined const parsed = Number(value) return Number.isFinite(parsed) ? parsed : undefined } +function withRemovedKeysAsNull(previous: unknown, next: unknown): unknown { + if (!isRecord(previous) || !isRecord(next)) return next + + const patch: Record = {} + for (const key of Object.keys(previous)) { + if (!(key in next)) patch[key] = null + } + for (const [key, value] of Object.entries(next)) { + patch[key] = withRemovedKeysAsNull(previous[key], value) + } + return patch +} + function sanitizeModelId(modelId: string): string { return modelId.replace(/[^a-zA-Z0-9._-]/g, '-') } @@ -101,32 +127,53 @@ function jsonObjectField(label: string) { }) } +function jsonContainerField(label: string) { + return z.string().superRefine((value, ctx) => { + if (!value.trim()) return + if (!parseJsonContainer(value)) { + ctx.addIssue({ + code: 'custom', + message: `${label} must be a valid JSON object or array`, + }) + } + }) +} + +function jsonArrayField(label: string) { + return z.string().superRefine((value, ctx) => { + if (!value.trim()) return + if (!parseJsonArray(value)) { + ctx.addIssue({ + code: 'custom', + message: `${label} must be a valid JSON array`, + }) + } + }) +} + const modelFormSchema = z.object({ providerId: z.string(), modelId: z.string().min(1, 'Model ID is required').regex(/^[a-zA-Z0-9._-]+$/, 'Must use only letters, numbers, dots, hyphens, and underscores'), backingModelId: z.string(), - providerModelProviderId: z.string(), displayName: z.string(), family: z.string(), - status: z.enum(['none', 'alpha', 'beta', 'deprecated', 'active']), releaseDate: z.string(), - apiUrl: z.string(), - apiNpm: z.string(), + modelPackage: z.string(), contextLimit: z.string(), inputLimit: z.string(), outputLimit: z.string(), - capabilitiesJson: jsonObjectField('Capabilities'), - costJson: jsonObjectField('Cost'), - optionsJson: jsonObjectField('Options'), + settingsJson: jsonObjectField('Settings'), headersJson: jsonObjectField('Headers'), - variantsJson: jsonObjectField('Variants'), + capabilitiesJson: jsonObjectField('Capabilities'), + costJson: jsonContainerField('Cost'), + variantsJson: jsonArrayField('Variants'), extraJson: jsonObjectField('Advanced fields'), createNewProvider: z.boolean(), newProviderType: z.enum(['api', 'npm']), newProviderId: z.string(), newProviderName: z.string().optional(), newProviderBaseUrl: z.string().optional(), - newProviderNpm: z.string().optional(), + newProviderPackage: z.string().optional(), }).superRefine((data, ctx) => { if (data.createNewProvider) { if (!data.newProviderId?.trim()) { @@ -137,8 +184,8 @@ const modelFormSchema = z.object({ if (data.newProviderType === 'api' && !data.newProviderBaseUrl?.trim()) { ctx.addIssue({ code: 'custom', message: 'Base URL is required for API providers', path: ['newProviderBaseUrl'] }) } - if (data.newProviderType === 'npm' && !data.newProviderNpm?.trim()) { - ctx.addIssue({ code: 'custom', message: 'NPM package is required for npm providers', path: ['newProviderNpm'] }) + if (data.newProviderType === 'npm' && !data.newProviderPackage?.trim()) { + ctx.addIssue({ code: 'custom', message: 'NPM package is required for npm providers', path: ['newProviderPackage'] }) } } else { if (!data.providerId?.trim()) { @@ -165,20 +212,20 @@ export interface NewProviderConfig { type: 'api' | 'npm' name?: string baseUrl?: string - npm?: string + package?: string } interface OpenCodeModelDialogProps { open: boolean onOpenChange: (open: boolean) => void - onSubmit: (providerId: string, modelId: string, model: ConfigModel, newProvider?: NewProviderConfig) => void + onSubmit: (providerId: string, modelId: string, model: OpenCodeProviderModelPatch, newProvider?: NewProviderConfig) => void availableProviders: string[] - existingProviders?: Record + existingProviders?: Record selectedProviderId: string editingModel?: { providerId: string modelId: string - model: ConfigModel + model: OpenCodeProviderModelConfig } } @@ -200,21 +247,18 @@ export function OpenCodeModelDialog({ return { providerId: editingModel.providerId, modelId: editingModel.modelId, - backingModelId: typeof editingModel.model.id === 'string' ? editingModel.model.id : '', - providerModelProviderId: typeof editingModel.model.providerID === 'string' ? editingModel.model.providerID : '', + backingModelId: editingModel.model.modelID || '', displayName: editingModel.model.name || '', family: editingModel.model.family || '', - status: (editingModel.model.status as ModelFormValues['status']) || 'none', - releaseDate: editingModel.model.release_date || '', - apiUrl: editingModel.model.api?.url || '', - apiNpm: editingModel.model.api?.npm || '', + releaseDate: editingModel.model.releaseDate || '', + modelPackage: editingModel.model.package || '', contextLimit: editingModel.model.limit?.context?.toString() || '', inputLimit: editingModel.model.limit?.input?.toString() || '', outputLimit: editingModel.model.limit?.output?.toString() || '', + settingsJson: stringifyJson(editingModel.model.settings), + headersJson: stringifyJson(editingModel.model.headers), capabilitiesJson: stringifyJson(editingModel.model.capabilities), costJson: stringifyJson(editingModel.model.cost), - optionsJson: stringifyJson(editingModel.model.options), - headersJson: stringifyJson(editingModel.model.headers), variantsJson: stringifyJson(editingModel.model.variants), extraJson: stringifyJson(extraEntries), createNewProvider: false, @@ -222,7 +266,7 @@ export function OpenCodeModelDialog({ newProviderId: '', newProviderName: '', newProviderBaseUrl: '', - newProviderNpm: '', + newProviderPackage: '', } } @@ -230,20 +274,17 @@ export function OpenCodeModelDialog({ providerId: selectedProviderId || availableProviders[0] || '', modelId: '', backingModelId: '', - providerModelProviderId: '', displayName: '', family: '', - status: 'none', releaseDate: '', - apiUrl: '', - apiNpm: '', + modelPackage: '', contextLimit: '', inputLimit: '', outputLimit: '', + settingsJson: '', + headersJson: '', capabilitiesJson: '', costJson: '', - optionsJson: '', - headersJson: '', variantsJson: '', extraJson: '', createNewProvider: availableProviders.length === 0, @@ -251,7 +292,7 @@ export function OpenCodeModelDialog({ newProviderId: '', newProviderName: '', newProviderBaseUrl: '', - newProviderNpm: '', + newProviderPackage: '', } }, [editingModel, selectedProviderId, availableProviders]) @@ -280,9 +321,8 @@ export function OpenCodeModelDialog({ if (createNewProvider) { return watchedNewProviderBaseUrl?.trim() || '' } - const provider = existingProviders?.[watchedProviderId] - const options = provider?.options as { baseURL?: string } | undefined - return (options?.baseURL || provider?.api || '').trim() + const baseURL = existingProviders?.[watchedProviderId]?.settings?.baseURL + return typeof baseURL === 'string' ? baseURL.trim() : '' }, [createNewProvider, watchedNewProviderBaseUrl, watchedProviderId, existingProviders]) const discoverModels = useCallback(async (forceRefresh = false) => { @@ -353,43 +393,41 @@ export function OpenCodeModelDialog({ const handleSubmit = (values: ModelFormValues) => { const extra = parseOptionalJsonField(values.extraJson) - const capabilities = parseOptionalJsonField(values.capabilitiesJson) - const cost = parseOptionalJsonField(values.costJson) - const options = parseOptionalJsonField(values.optionsJson) + const settings = parseOptionalJsonField(values.settingsJson) const headers = parseOptionalJsonField(values.headersJson) - const variants = parseOptionalJsonField(values.variantsJson) - - const model: ConfigModel = { - ...(extra || {}), - } - - if (values.backingModelId.trim()) model.id = values.backingModelId.trim() - if (values.providerModelProviderId.trim()) model.providerID = values.providerModelProviderId.trim() - if (values.displayName.trim()) model.name = values.displayName.trim() - if (values.family.trim()) model.family = values.family.trim() - if (values.status !== 'none') model.status = values.status - if (values.releaseDate.trim()) model.release_date = values.releaseDate.trim() - - if (values.apiUrl.trim() || values.apiNpm.trim()) { - model.api = { url: values.apiUrl.trim(), ...(values.apiNpm.trim() ? { npm: values.apiNpm.trim() } : {}) } + const capabilities = parseOptionalJsonField(values.capabilitiesJson) + const cost = parseOptionalJsonContainerField(values.costJson) + const variants = parseOptionalJsonArrayField(values.variantsJson) + + const builtModel: OpenCodeProviderModelPatch = { + ...(extra ?? {}), + modelID: values.backingModelId.trim() || null, + name: values.displayName.trim() || null, + family: values.family.trim() || null, + releaseDate: values.releaseDate.trim() || null, + package: values.modelPackage.trim() || null, } const contextLimit = parseOptionalNumber(values.contextLimit) const inputLimit = parseOptionalNumber(values.inputLimit) const outputLimit = parseOptionalNumber(values.outputLimit) - if (contextLimit !== undefined || inputLimit !== undefined || outputLimit !== undefined) { - model.limit = { - ...(contextLimit !== undefined ? { context: contextLimit } : {}), - ...(inputLimit !== undefined ? { input: inputLimit } : {}), - ...(outputLimit !== undefined ? { output: outputLimit } : {}), - } as ConfigModel['limit'] - } - - if (capabilities) model.capabilities = capabilities as ConfigModel['capabilities'] - if (cost) model.cost = cost as ConfigModel['cost'] - if (options) model.options = options - if (headers) model.headers = headers as Record - if (variants) model.variants = variants as ConfigModel['variants'] + builtModel.limit = contextLimit !== undefined || inputLimit !== undefined || outputLimit !== undefined + ? { + ...(contextLimit !== undefined ? { context: contextLimit } : {}), + ...(inputLimit !== undefined ? { input: inputLimit } : {}), + ...(outputLimit !== undefined ? { output: outputLimit } : {}), + } + : null + + builtModel.settings = (settings as OpenCodeProviderModelConfig['settings'] | undefined) ?? null + builtModel.headers = (headers as Record | undefined) ?? null + builtModel.capabilities = (capabilities as OpenCodeProviderModelConfig['capabilities'] | undefined) ?? null + builtModel.cost = (cost as OpenCodeProviderModelConfig['cost'] | undefined) ?? null + builtModel.variants = (variants as OpenCodeProviderModelConfig['variants'] | undefined) ?? null + + const model = editingModel + ? withRemovedKeysAsNull(editingModel.model, builtModel) as OpenCodeProviderModelPatch + : builtModel let newProvider: NewProviderConfig | undefined if (values.createNewProvider) { @@ -398,7 +436,7 @@ export function OpenCodeModelDialog({ type: values.newProviderType, name: values.newProviderName || undefined, baseUrl: values.newProviderBaseUrl || undefined, - npm: values.newProviderNpm || undefined, + package: values.newProviderPackage || undefined, } } @@ -516,7 +554,7 @@ export function OpenCodeModelDialog({ )} {newProviderType === 'npm' && ( - ( + ( NPM Package @@ -596,7 +634,7 @@ export function OpenCodeModelDialog({ )} /> -
+
( Display Name @@ -612,64 +650,23 @@ export function OpenCodeModelDialog({ )} /> -
- -
- ( - - Status - - - - )} /> - ( + ( - Release Date - + Package + )} />
-
- ( - - Model Provider ID - - - - )} /> - -
- ( - - API URL - - - - )} /> - - ( - - API NPM - - - - )} /> -
-
+ ( + + Release Date + + + + )} />
( @@ -700,9 +697,9 @@ export function OpenCodeModelDialog({

Structured JSON fields

- ( + ( - Options JSON + Settings JSON