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..0ad8be135 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,16 @@ 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 { patchNamesOpenCodeConfigEntities } from '../services/opencode-config-references' +import { opencodeServerManager } from '../services/opencode-single-server' import { logger } from '../utils/logger' interface OpenCodeConfigRoutesOptions { @@ -53,6 +57,26 @@ function mergeMcpServerStatus(views: McpServerView[], status: McpStatusMap) { }) } +/** + * Maps a config read failure to a JSON error response: 503 when the OpenCode server is unreachable, + * 502 for other client errors, and 500 otherwise, using `message` as the client-facing text. + */ +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) +} + +/** + * Serves the OpenCode config read routes (`/`, `/effective`, `/managed`, `/mcp`) and the `PUT` and + * `PATCH` write routes. Reads and writes take the shared config file lock, `redactSecrets` masks + * secret values in every response, and a `PATCH` that names config entities is rejected with 409 + * while OpenCode has not yet loaded the saved configuration. + */ export function createOpenCodeConfigRoutes( settingsService: SettingsService, openCodeClient: OpenCodeClient, @@ -90,30 +114,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 +186,12 @@ export function createOpenCodeConfigRoutes( return c.json({ error: 'Invalid config data', details: parsed.error.issues }, 400) } + if (opencodeServerManager.isRestartPending() && patchNamesOpenCodeConfigEntities(parsed.data.patch)) { + 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, @@ -165,6 +205,11 @@ export function createOpenCodeConfigRoutes( return app } +/** + * Applies a validated config update and maps the outcome to a JSON response, redacting secrets when + * `redactSecrets` is set and translating conflict, invalid-source, shadowed-removal, redacted-value + * and schema-validation failures into their matching status codes. + */ async function applyUpdate( c: Context, input: Parameters[0], @@ -198,12 +243,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..747cff529 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, @@ -112,6 +113,12 @@ function rewriteRepoLocationBody(bodyText: string, resolveDirectory: RepoDirecto return JSON.stringify(parsed) } +/** + * Serves `/repos/:repoId/*` and `/*` by forwarding to the managed OpenCode server, rewriting repo + * directory references in the path, query and JSON body. Every request needs the internal token + * except a ticketed persistent PTY WebSocket connect, which is origin-checked and bridged to the + * upstream socket. + */ export function createOpenCodeProxyRoutes(db: Database, settingsService: SettingsService, upgradeWebSocket: UpgradeWebSocket) { const app = new Hono() const requireInternalToken = createInternalTokenMiddleware(db) @@ -144,6 +151,11 @@ export function createOpenCodeProxyRoutes(db: Database, settingsService: Setting return (await upgrade(c, async () => {})) ?? c.body(null) } + /** + * Forwards the current request to the managed OpenCode server with basic auth and a default + * directory, letting the optional rewrites adjust path, query and JSON body first. Returns 503 + * before lifecycle initialization, 501 for non-PTY upgrades and 502 when the upstream call fails. + */ async function forwardToOpenCode( c: Context, pathSuffix: string, @@ -192,20 +204,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..e920bbbae 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/' @@ -79,6 +80,11 @@ function isWithinUpstreamApiPath(basePath: string, pathname: string): boolean { return pathname.startsWith(`${basePath}/api/`) } +/** + * Serves `/:serverId/*` behind `requireAuth`, proxying each request to the registry-resolved remote + * OpenCode server while keeping the normalized upstream pathname inside that connection's own `/api/` + * space. Maps an upstream header timeout to 504 and an unreachable server to 502. + */ export function createOpenCodeServerProxyRoutes( registry: OpenCodeServerRegistry, requireAuth: MiddlewareHandler, @@ -117,58 +123,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..5bab0b03b 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({ @@ -41,6 +41,11 @@ function withRepoSettings(database: Database, repo: Repo): Repo { } } +/** + * Mounts the repo git, terminal, project-config, schedule and secret/variable sub-routers, and serves + * repo CRUD with discovery, ordering, access, credential, identity, workspace, pull/branch, download + * and assistant-mode routes. Handler failures are mapped to their status codes with a JSON message. + */ export function createRepoRoutes( database: Database, gitAuthService: GitAuthService, @@ -614,6 +619,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..cbe01d131 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,19 @@ 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' +import { withFileLock } from '../utils/atomic-json' const ASSISTANT_MODE_DIR = ASSISTANT_REPO_PATH @@ -784,9 +790,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\` with \`expectedRevision\` and \`actualRevision\` means the revision is stale, and a \`409\` with \`paths\` means 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. A \`409\` with neither field means OpenCode has not loaded the saved configuration since a failed reload, so a patch naming an agent, command, provider or MCP server is refused: do not retry, and tell the user to restart the OpenCode server from Settings. Patches that name no such entity still apply. **Request Body:** \`\`\`ts @@ -814,35 +820,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, the v1 entry to remove exists only in a lower-priority config source, or an entity patch arrives while an OpenCode restart is pending. ### 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 +857,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,23 +1134,41 @@ Fork a session, optionally before a specific message. Omit \`beforeMessageId\` t ` } -export function buildAssistantOpenCodeConfig(): OpenCodeConfigInput { - const config: OpenCodeConfigInput = { +/** + * Builds the canonical Assistant `opencode.json` document: the assistant agent as default agent, its + * allowed permissions, and a `primary` agent entry. The result is schema-validated, and an invalid + * result throws rather than returning a config OpenCode would reject. + */ +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 } +/** + * Creates or repairs the Assistant workspace for `repo`: writes AGENTS.md, the assistant OpenCode + * config, the bundled skills and the default agent file, updating the config under a file lock and + * preserving customized content unless the matching `overwrite*` option is set. Returns the + * resulting status, including warnings for instruction updates it declined to apply. + */ export async function ensureAssistantMode( repo: Repo, options?: AssistantModeInitRequest, @@ -1158,8 +1183,6 @@ export async function ensureAssistantMode( const assistantAgentPath = getAssistantDefaultAgentPath(assistantDir) const agentsMdExists = await fileExists(agentsMdPath) - const opencodeJsonExists = await fileExists(opencodeJsonPath) - const overwriteOpenCodeConfig = options?.overwriteOpenCodeConfig ?? false const overwriteAgentsMd = options?.overwriteAgentsMd ?? false @@ -1186,47 +1209,36 @@ export async function ensureAssistantMode( await writeFileContent(agentsMdPath, agentsMdContent) } - const hasLegacyOpenCodeConfig = opencodeJsonExists && await isLegacyAssistantOpenCodeConfig(opencodeJsonPath) - - 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)) - 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 - const updatedConfig = assistantOpenCodeConfigHasGeneratedAgentPersona(repairedConfig) - ? stripGeneratedAssistantAgentPersona(repairedConfig) - : repairedConfig - - if (updatedConfig !== existingConfig) { - await writeFileContent(opencodeJsonPath, JSON.stringify(updatedConfig, null, 2)) - opencodeJsonUpdated = true - } - } catch { - const config = buildAssistantOpenCodeConfig() - await writeFileContent(opencodeJsonPath, JSON.stringify(config, null, 2)) - opencodeJsonUpdated = true + const opencodeJsonUpdated = await withFileLock(opencodeJsonPath, async () => { + const opencodeJsonExists = await fileExists(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 + + if (!opencodeJsonExists || overwriteOpenCodeConfig) { + await writeFileContent(opencodeJsonPath, JSON.stringify(buildAssistantOpenCodeConfig(), null, 2)) + return true + } + if (hasV1OpenCodeConfig) return false + if (existingOpenCodeConfig === undefined) { + await writeFileContent(opencodeJsonPath, JSON.stringify(buildAssistantOpenCodeConfig(), null, 2)) + return true } - } + + const repairedConfig = assistantOpenCodeConfigNeedsRepair(existingOpenCodeConfig) + ? mergeAssistantOpenCodeConfig(existingOpenCodeConfig) + : existingOpenCodeConfig + const updatedConfig = assistantOpenCodeConfigHasGeneratedAgentPersona(repairedConfig) + ? stripGeneratedAssistantAgentPersona(repairedConfig) + : repairedConfig + if (updatedConfig === existingOpenCodeConfig) return false + + await writeFileContent(opencodeJsonPath, JSON.stringify(updatedConfig, null, 2)) + return true + }) await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR)) await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_AGENTS_DIR)) @@ -1350,89 +1362,248 @@ export async function ensureAssistantMode( } } -function assistantOpenCodeConfigNeedsRepair(config: OpenCodeConfigInput): boolean { +/** + * Parses JSONC config text into a config object, returning `undefined` when the text is malformed or + * its root is not an object. + */ +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']) + +/** + * Reports whether the config still carries v1 keys, which mark a file that has not been converted to + * the v2 shape yet. + */ +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]) +} + +/** + * Reports whether an existing Assistant config lacks what `ensureAssistantMode` requires: the + * assistant as default agent, an enabled agent entry with a valid mode, and no generated persona left + * inline. + */ +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 } +/** Returns the agent's `mode` when it is `primary` or `all`, otherwise falling back to `primary`. */ function resolveValidAssistantMode(agent: unknown): 'primary' | 'all' { const mode = (agent as { mode?: unknown } | undefined)?.mode 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) } +} + +/** + * Rewrites the assistant agent entry to a mode-only value, dropping a generated `system` prompt so it + * comes from the agent markdown file rather than opaque config text. + */ +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 { +/** + * Merges an existing Assistant config over a freshly generated one, keeping user settings and extra + * agents while forcing the assistant as default agent and re-enabling its entry. + */ +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 { +/** + * Lists the v1 paths to delete after a conversion: the assistant agent entry, widened to the whole v1 + * `agents` container when that container held nothing else, plus `permissions` when a v2 counterpart + * has just been written. + */ +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 +} + +/** + * Picks the assistant agent written back to the converted file, preferring OpenCode's converted entry, + * then the agent passed in, then a mode derived from the v1 definition. + */ +function resolveConvertedAssistantAgent( + agent: OpenCodeAgentConfig | undefined, + v1Agent: unknown, +): OpenCodeAgentConfig { + return normalizeAssistantAgent(agent) ?? agent ?? { mode: resolveValidAssistantMode(v1Agent) } +} + +/** + * Puts the raw `{env:…}` and `{file:…}` references from the original file back into the converted + * assistant agent, returning the agent unchanged when no restored entry can be read. + */ +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 { + const assistantDir = getAssistantModeDirectory() + const opencodeJsonPath = path.join(assistantDir, ASSISTANT_OPENCODE_CONFIG_FILENAME) try { - const content = await readFileContent(opencodeJsonPath) - const config = JSON.parse(content) as { - permission?: { allow?: unknown; ask?: unknown } - } - if (Array.isArray(config.permission?.allow) || Array.isArray(config.permission?.ask)) return true + return await withFileLock(opencodeJsonPath, () => convertAssistantOpenCodeConfigFile(api, assistantDir, opencodeJsonPath)) + } catch (error) { + logger.warn('Skipped the Assistant OpenCode config conversion:', error) return false - } catch { + } +} + +/** + * Converts one Assistant `opencode.json` in place when it still holds v1 keys, writing OpenCode's + * converted agent and permissions while preserving comments. Returns `false` without writing when the + * file is absent, already v2, unreadable by OpenCode, or the rewritten text fails validation. + */ +async function convertAssistantOpenCodeConfigFile( + api: OpenCodeApi, + assistantDir: string, + opencodeJsonPath: string, +): Promise { + 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 + if (isRecord(v1AssistantAgent) && documentInfo.agents?.[ASSISTANT_DEFAULT_AGENT_NAME] === undefined) { + logger.warn('Skipped the Assistant OpenCode config conversion: OpenCode returned no converted assistant agent') + return false + } + 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 }) + } + + let updatedText = applyOpenCodeConfigEdits(rawContent, setOperations) + for (const removalPath of buildAssistantV1ConfigRemovalPaths(rawConfig, documentInfo.permissions)) { + updatedText = removeOpenCodeConfigProperty(updatedText, removalPath) + } + + const validation = validateOpenCodeConfig(parseJsonc(updatedText)) + if (!validation.success) { + logger.warn('Skipped the Assistant OpenCode config conversion: the converted config is invalid', validation.issues) return false } + + await writeFileContent(opencodeJsonPath, updatedText) + return true } export async function getAssistantModeStatus(repo: Repo): Promise { diff --git a/backend/src/services/opencode-config-apply.ts b/backend/src/services/opencode-config-apply.ts index 0cc76be2f..2691e275e 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,39 @@ function requiresOpenCodeReload(previous: OpenCodeConfigFile | null, next: OpenC return listChangedTopLevelKeys(previous?.content ?? {}, next.content).length > 0 } +/** + * Replaces substituted values in a merge patch with the `{env:…}` or `{file:…}` references held by + * the raw config, reading the resolved config from the OpenCode server. Returns the patch unchanged + * when it does not name any config entity. + */ +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) +} + +/** + * Applies config content or a merge patch under the shared config file lock, saving the previous + * valid config as last-known-good and restoring it if that save fails. OpenCode is reloaded only when + * top-level keys changed, and a failed reload is reported as `restart_pending`. + */ 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..161d4479e --- /dev/null +++ b/backend/src/services/opencode-config-effective.ts @@ -0,0 +1,50 @@ +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 +} + +/** + * Merges the OpenCode config documents the server reports for the workspace, keeping only the given + * managed sources. Documents OpenCode did not load are skipped, so the result reflects only values + * the server actually resolved. + */ +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..5bd4482d0 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,38 @@ 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 +} + +/** + * Resolves a JSON path against parsed config content, walking objects by key and arrays by index. + * Returns whether the path exists plus its value; an absent path yields `found: false`, and + * `nullAncestorCountsAsFound` treats a null intermediate value as terminal instead of missing. + */ +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 +288,12 @@ interface MergeOpenCodeConfigValuesOptions { deleteNullValues?: boolean } -function mergeOpenCodeConfigValues( +/** + * Deep-merges `source` into `target` and returns a new object, recursing into plain-object values. + * With `deleteNullValues`, a null source value removes the key and nested nulls are stripped from + * replaced values. + */ +export function mergeOpenCodeConfigValues( target: Record, source: Record, options: MergeOpenCodeConfigValuesOptions = {}, @@ -271,13 +326,19 @@ 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, - })) +/** + * Validates parsed config content against the OpenCode config schema and throws + * OpenCodeConfigValidationError carrying the reported issues when it fails. + */ +function assertValidOpenCodeConfig(value: unknown): void { + const result = validateOpenCodeConfig(value) + if (!result.success) throw new OpenCodeConfigValidationError(result.issues) } +/** + * Parses JSONC config text and validates it against the OpenCode config schema, returning the parsed + * object (empty when parsing fails) with `isValid` and any validation issues instead of throwing. + */ export function parseOpenCodeConfigContent(rawContent: string): OpenCodeConfigParseResult { let parsed: unknown try { @@ -294,7 +355,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 +363,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 { @@ -375,6 +436,11 @@ export async function readOpenCodeConfigSnapshot(): Promise source.validationIssues ?? []) return { @@ -402,7 +468,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 @@ -415,6 +481,11 @@ export async function writeOpenCodeConfigFile( return toOpenCodeConfigFile(await readOpenCodeConfigSnapshot()) } +/** + * Computes the JSON-path operations that turn `current` into `next`, recursing into plain objects on + * both sides. Merge mode treats `null` as a deletion and strips null members from merged values, + * replace mode additionally removes keys absent from `next`. + */ function collectOpenCodeConfigPathOperations( next: Record, current: Record, @@ -452,7 +523,163 @@ function collectOpenCodeConfigPathOperations( return operations } -function applyOpenCodeConfigPathOperations( +/** + * Returns the v1 entity paths a v2 merge patch removes, since OpenCode still reads v1 containers. + * Container-wide removals carry no `container`, while named removals record their v1 container so + * it can be pruned once left empty. + */ +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 +} + +/** + * Returns the offset of the first comma in `rawContent` between `start` and `end`, skipping line + * and block comments, or -1 when none exists or a comment runs past `end`. + */ +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 +} + +/** + * Returns the offset just past the line break that follows `offset`, ignoring intervening spaces + * and tabs, or null when the line has more content after them. + */ +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 +} + +/** + * Builds JSONC text edits that delete the property at `jsonPath` while leaving surrounding comments + * intact. Returns null when the node, its container, or a required separator comma cannot be + * located; a comment between the property and the next member makes the property and the comma be + * removed by separate edits so the comment survives. + */ +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) +} + +/** + * Returns the distinct containers named by `removals` that remain empty objects in the updated raw + * config text, so they can be pruned. Yields nothing when the parsed root is not an object. + */ +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 { @@ -466,6 +693,10 @@ function applyOpenCodeConfigPathOperations( return text } +/** + * Throws OpenCodeConfigShadowedRemovalError when a planned removal targets a path the target source + * does not define but another config source does, listing the shadowed paths and their sources. + */ function assertNoShadowedOpenCodeConfigRemovals( operations: OpenCodeConfigPathOperation[], targetSource: OpenCodeConfigSourceFile | undefined, @@ -492,6 +723,11 @@ function assertNoShadowedOpenCodeConfigRemovals( } } +/** + * Writes new config text, or applies object content/patch, to the requested source or the preferred + * writable one. Enforces `expectedRevision`, rejects redacted values and invalid sibling sources, + * preserves JSONC comments and formatting, and returns the refreshed config snapshot. + */ export async function updateOpenCodeConfigFile( content: Record | string, options: UpdateOpenCodeConfigOptions = {}, @@ -525,17 +761,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) }) } @@ -685,6 +934,12 @@ async function applyOpenCodeConfigSourceFileStates( } } +/** + * Restores the config source files from a serialized snapshot: sources present in the snapshot are + * written, sources absent from it but currently on disk are removed, and the legacy config.json source + * is folded afterwards. All writes roll back together on failure, and the refreshed file view is + * returned, or null when no sources remain. + */ export async function restoreOpenCodeConfigSnapshot(snapshot: string): Promise { const desired = parseOpenCodeConfigSnapshot(snapshot) const current = await readOpenCodeConfigSnapshot() @@ -717,6 +972,11 @@ async function archiveLegacyConfigJsonSource(rawContent: string): Promise { const legacyPath = getOpenCodeConfigSourcePath(LEGACY_OPENCODE_CONFIG_SOURCE_NAME) @@ -754,8 +1014,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..9f1eae2e0 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([ @@ -53,24 +53,24 @@ function isSecretKey(key: string): boolean { return SECRET_KEYS.has(normalizeSecretKey(key)) } +/** + * Reports whether a config path points at a map of user-chosen names (`providers`, `agents`, + * `commands`, `mcp.servers`), so its keys are treated as names rather than secret keys. + */ 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)) @@ -85,6 +85,10 @@ function redactOpaqueValue(value: unknown, path: string[], redactedPaths: string ) } +/** + * Recursively copies a config value with secret-named strings and the contents of `headers` and + * `environment` replaced by the redaction placeholder, appending each redacted path to `redactedPaths`. + */ function redactValue(value: unknown, path: string[], redactedPaths: string[]): unknown { if (Array.isArray(value)) { return value.map((item, index) => redactValue(item, [...path, String(index)], redactedPaths)) @@ -95,7 +99,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..6b80a8f50 --- /dev/null +++ b/backend/src/services/opencode-config-references.ts @@ -0,0 +1,184 @@ +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 +} + +/** Whether `path` begins with every segment of `prefix`, used to match field-alias prefixes. */ +function startsWithPath(path: readonly string[], prefix: readonly string[]): boolean { + return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment) +} + +/** + * Finds the raw `{env:…}` or `{file:…}` reference stored for `path` in any candidate entity, + * also checking each alias-mapped path. Returns undefined when no candidate holds a reference there. + */ +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 +} + +/** + * Walks a patch value and swaps each string that still equals its resolved counterpart for the raw + * config reference at the same path. Returns a new value; edited strings and strings without a + * stored reference are kept as given. + */ +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 +} + +/** + * Returns the single entity name the patch removes but the resolved container still contains, or + * undefined when the patch removes zero or several names. + */ +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 +} + +/** + * Restores references for every named entity in a patch container, resolving the source entity from + * the resolved container or from a single rename within the same patch. Returns the patch container + * unchanged when the resolved container is not an object. + */ +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)] + }), + ) +} + +/** + * Returns a copy of `target` with `value` written at `path`, creating intermediate plain objects + * where they are missing. An empty path returns `target` itself. + */ +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..a247f5f2e 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. @@ -79,31 +105,38 @@ export function applySessionActivity(busySessions: Set, event: V2Event): } } +/** + * Returns a builder that stamps each replayed permission, form or session-status event with the + * current time and a monotonically increasing `${idPrefix}${count}` id, so replayed events stay + * ordered and distinct from live ones. + */ 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 +150,7 @@ export function parseOpenCodeEvent(data: string, sessionDirectories: Map { @@ -147,21 +180,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..76dc73d99 --- /dev/null +++ b/backend/src/services/opencode-event-subscription.ts @@ -0,0 +1,182 @@ +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 + + /** + * Clears the watchdog and aborts the in-flight attempt so its stream iterator ends. + */ + const stopAttempt = (): void => { + const attempt = currentAttempt + currentAttempt = null + if (!attempt) return + if (attempt.watchdog) { + clearInterval(attempt.watchdog) + attempt.watchdog = null + } + attempt.controller.abort() + } + + /** + * Cancels a pending reconnect wait and resolves it at once, letting the loop observe that it has + * been closed or superseded. + */ + 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) + }) + + /** + * Runs the reconnect loop for one loop token, opening a fresh attempt per iteration, aborting it + * when the stall watchdog fires, and waiting a doubling backoff before retrying. Returns once the + * subscription is closed or `token` is superseded by a newer loop. + */ + 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) { + lastActivityAt = Date.now() + 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..eb69e81a2 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 @@ -1036,8 +1037,27 @@ class OpenCodeServerManager { return this.sandboxEnforced } + /** + * Sets the lifecycle gate and, when it transitions from closed to open, notifies the registered + * listeners that the proxy is now serving requests. Listener failures are logged, not propagated. + */ 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..710dab485 100644 --- a/backend/src/services/opencode/client.ts +++ b/backend/src/services/opencode/client.ts @@ -1,10 +1,13 @@ 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' +const FORWARD_EXCLUDED_HEADERS: ReadonlySet = new Set(['cookie']) + export interface OpenCodeClient { readonly api: OpenCodeApi forwardRaw(request: Request): Promise @@ -61,76 +64,40 @@ 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 ?? {}) }) - + /** + * Proxies a raw request to the OpenCode server, dropping the `/api/opencode` prefix and applying the + * directory header and basic auth. Upstream failures become a `502` JSON response instead of a throw. + */ + 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, FORWARD_EXCLUDED_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..08bbdbe56 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 @@ -184,6 +155,10 @@ export class RemoteServerEventHub { return false } + /** + * Delivers already-built replay events to one client through the aggregator, which drops them unless + * the client still subscribes to that server and directory. + */ private emitReplayEventsToClient( clientId: string, serverId: string, @@ -191,7 +166,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) } } @@ -210,103 +185,96 @@ export class RemoteServerEventHub { } } + /** + * Replaces the server's event subscription with a fresh one, recording a stream error instead when the + * registry cannot provide a connection. Does nothing until the hub has started, so stale callbacks from + * the prior subscription are ignored rather than applied. + */ private connectServer(serverId: string): void { 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 { + /** + * Marks the server's stream connected, announces it to the aggregator, and starts version probing + * and session status replay. A callback from a superseded subscription is ignored. + */ + 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 { + /** + * Records a disconnect on the server's stream and clears its connected flag, leaving the + * subscription itself to keep reconnecting. + */ + 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 { + /** + * Routes one upstream event to the aggregator while maintaining the hub's per-stream bookkeeping: + * busy-session activity, subagent session ids, and the directory the event resolves to. + */ + 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) } + /** + * Keeps streams aligned with the server registry: deleted, unreadable, or disabled servers have their + * stream forgotten, while added and updated servers get their cached version state cleared and are + * reconnected. + */ private handleRegistryChange(change: OpenCodeServerChange): void { if (!this.started) return @@ -331,7 +299,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 +306,26 @@ 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 + /** + * Reports whether the subscription still owns the server's live stream, so async work started under + * it does not act on a stream that has since been replaced or disconnected. + */ + 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 { + /** + * Re-emits idle and busy status events for the server's active sessions after a reconnect, so + * clients that missed updates while disconnected resync. Stops quietly at any await once the stream + * is no longer current. + */ + 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 +343,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++ } @@ -391,6 +371,11 @@ export class RemoteServerEventHub { } } + /** + * Looks up the working directory of active sessions the stream has not mapped yet, caching each result + * in `sessionDirectories`. Sessions are fetched concurrently and left unresolved when their lookup + * fails, so replay proceeds with whatever directories are known. + */ private async resolveActiveSessionDirectories( api: OpenCodeApi, serverId: string, @@ -411,6 +396,11 @@ export class RemoteServerEventHub { })) } + /** + * Probes the server's OpenCode version, skipping when a probe is already pending or the version is + * known. Results are applied only when the probe token still matches the current stream, and a failed + * probe reopens the request flag so a later reconnect can retry it. + */ private async refreshVersion(serverId: string): Promise { const stream = this.streams.get(serverId) if (!stream || stream.versionRequested) return @@ -437,14 +427,16 @@ export class RemoteServerEventHub { current.versionRequested = false } + /** + * Returns the server's stream bookkeeping record, creating an empty one on first use so callers can + * record state before a subscription exists. + */ private ensureStream(serverId: string): RemoteServerStream { const existing = this.streams.get(serverId) if (existing) return existing const stream: RemoteServerStream = { - source: null, - reconnectTimeout: null, - reconnectDelay: this.reconnectDelayMs, + subscription: null, connected: false, error: null, version: null, @@ -464,20 +456,19 @@ export class RemoteServerEventHub { stream.error = error } - private closeSource(stream: RemoteServerStream): void { - if (!stream.source) return - stream.source.close() - stream.source = null + /** + * Closes the stream's subscription and clears the handle, keeping the stream entry itself. + */ + 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..d2196471d 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,17 +83,36 @@ 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() + } + + /** + * Forces the upstream event subscription to reconnect, used after credentials change. No-op while the + * aggregator is not started. + */ 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() } + /** + * Returns the process-wide aggregator, creating it on first call. All events and client registrations + * flow through this single instance. + */ static getInstance(): SSEAggregator { if (!SSEAggregator.instance) { SSEAggregator.instance = new SSEAggregator() @@ -114,10 +120,33 @@ class SSEAggregator { return SSEAggregator.instance } + /** + * Subscribes to local OpenCode events once per process, wiring the open, event, and disconnect handlers. + * Without a registered OpenCode client it logs a warning and stays stopped. + */ 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 { @@ -236,6 +265,10 @@ class SSEAggregator { } } + /** + * Runs the remote replay handler for one server directory, catching and logging failures so that a + * directory that cannot replay does not reject the caller. + */ private async replayRemoteDirectory( clientId: string, serverId: string, @@ -249,22 +282,26 @@ class SSEAggregator { } } + /** + * Replays pending permission and form events for each of the client's directories, settling every + * directory independently so one failed lookup does not block the rest. + */ 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', @@ -275,6 +312,10 @@ class SSEAggregator { this.emitReplayEventsToClient(clientId, directory, events.forms) } + /** + * Writes replayed events to a client only while it is still registered and subscribed to the directory; + * delivery stops at the first write error, leaving the remaining events unsent. + */ private emitReplayEventsToClient(clientId: string, directory: string, events: V2Event[]): void { if (events.length === 0) return @@ -293,13 +334,18 @@ class SSEAggregator { logger.info(`replay: sent ${events.length} pending action(s) for ${directory} to client ${clientId}`) } + /** + * Re-emits idle and busy status events for sessions the aggregator already tracks, so clients recover + * transitions missed while disconnected. Busy sessions with an unknown directory are resolved through + * the API and skipped when that lookup fails. + */ 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 +357,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++ } @@ -331,6 +377,10 @@ class SSEAggregator { } } + /** + * Collects every session currently active or scheduled, keyed by session id, and records the owning + * directory for each so a later replay can address it. + */ private getTrackedSessions(): Map { const tracked = new Map() this.activeSessions.forEach((sessionIDs, directory) => { @@ -343,9 +393,13 @@ class SSEAggregator { return tracked } - private async resolveSessionDirectory(fetcher: PendingActionsFetcher, sessionID: string): Promise { + /** + * Resolves a session's directory through the API and caches it for later event routing, returning + * `null` and logging a warning when the lookup fails. + */ + 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,61 +408,30 @@ 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) - } - } - + /** + * Handles a (re)connected upstream stream: broadcasts a resync, or defers it as pending while the resync + * gate is closed, and replays tracked session statuses only on a reconnection rather than the first + * connect. + */ 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() } } + /** + * Announces a fresh upstream connection to the connected-listeners and to clients subscribed to the + * local OpenCode server, isolating any listener that throws. + */ private notifyUpstreamConnected(): void { this.upstreamConnectedListeners.forEach(listener => { try { listener() } catch { /* ignore listener errors */ } @@ -420,15 +443,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) } @@ -449,12 +463,20 @@ class SSEAggregator { return () => { this.serverConnectedListeners.delete(listener) } } + /** + * Fans an event out to every server-event listener, isolating each listener so a throwing one cannot + * block the others or the dispatch caller. + */ notifyServerEvent(serverId: string, directory: string | null, event: SSEEvent): void { this.serverEventListeners.forEach(listener => { try { listener(serverId, directory, event) } catch { /* ignore listener errors */ } }) } + /** + * Notifies server-connected listeners that a server's stream is available, swallowing per-listener + * errors so one failure does not interrupt the notification of the rest. + */ notifyServerConnected(serverId: string): void { this.serverConnectedListeners.forEach(listener => { try { listener(serverId) } catch { /* ignore listener errors */ } @@ -465,46 +487,48 @@ 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 + /** + * Dispatches one local upstream event: directory-scoped events go to that directory's subscribers, + * catalog updates go to every client, and events without a resolvable directory go to all listeners + * unscoped. + */ + 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 { + /** + * Delivers an event to listeners and to the clients subscribed to its directory, writing no client + * frames when no client watches that directory. + */ + 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 { @@ -512,6 +536,10 @@ class SSEAggregator { this.notifyListeners(directory, event) } + /** + * Fans an event out to local listeners with its directory scope and reports it as a local-server event, + * swallowing individual listener failures. + */ private notifyListeners(directory: string | null, event: SSEEvent): void { this.eventListeners.forEach(listener => { try { listener(directory, event) } catch { /* ignore listener errors */ } @@ -522,18 +550,22 @@ 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') } } + /** + * Writes one already-encoded frame to a client, logging the labeled error instead of propagating a + * failure so one broken connection cannot interrupt a broadcast. + */ private writeFrame(client: SSEClient, frame: Uint8Array, errorLabel: string): void { try { client.writeFrame(frame) @@ -546,15 +578,19 @@ 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 { + /** + * Sends a remote server event to a single client, but only when that client is subscribed to the + * server and directory the event belongs to. + */ + 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') } @@ -569,6 +605,10 @@ class SSEAggregator { } } + /** + * Collects the IDs of clients subscribed to at least one directory of the given server, each client + * listed once regardless of how many of its directories match. + */ private clientIdsForServer(serverId: string): string[] { const clientIds: string[] = [] for (const client of this.clients.values()) { @@ -582,6 +622,11 @@ class SSEAggregator { return clientIds } + /** + * Updates active-session and subagent bookkeeping from local events before any listener sees them, so + * status queries reflect the event being dispatched: status and execution events move sessions between + * active and idle, deleted sessions are dropped, and sessions with a parent are marked as subagents. + */ private handleEvent(directory: string, event: SSEEvent): void { switch (event.type) { case 'session.status': { @@ -616,6 +661,7 @@ class SSEAggregator { } case 'session.deleted': { this.unmarkSubagentSession(directory, event.data.sessionID) + this.markSessionIdle(directory, event.data.sessionID) return } } @@ -650,6 +696,10 @@ class SSEAggregator { logger.info(`Session active: ${sessionID} in ${directory} (${sessions.size} active)`) } + /** + * Drops a session from the active set of its directory, forgetting the directory once it has no + * active sessions left. + */ private markSessionIdle(directory: string, sessionID: string): void { const sessions = this.activeSessions.get(directory) if (sessions) { @@ -671,6 +721,11 @@ class SSEAggregator { return this.clients.size } + /** + * Records whether a client is in the foreground and which session and server it is viewing, clearing + * both view fields when it is not visible and defaulting the server to the local one. Returns false + * when the client is unknown, leaving stored visibility untouched. + */ setClientVisibility( id: string, visible: boolean, @@ -713,6 +768,10 @@ class SSEAggregator { return new Set(this.getScheduledSessions().map(ref => ref.sessionID)) } + /** + * Reads scheduled session references from the resolver installed by the scheduler, yielding an empty + * list when no resolver is set. + */ private getScheduledSessions(): ScheduledSessionRef[] { return this.scheduledSessionsResolver?.() ?? [] } @@ -731,16 +790,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..0d927b9db 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,50 @@ 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('applies a PATCH that names no entity while an OpenCode restart is pending', async () => { + vi.mocked(opencodeServerManager.isRestartPending).mockReturnValueOnce(true) + mockApplyOpenCodeConfigUpdate.mockResolvedValueOnce({ status: 'applied', config: { revision: 'r2' } }) + + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ patch: { theme: 'dark' } }), + })) + + expect(res.status).toBe(200) + expect(mockApplyOpenCodeConfigUpdate).toHaveBeenCalledWith( + expect.objectContaining({ content: { theme: 'dark' }, mode: 'merge' }), + ) + }) + 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..196c27181 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,273 @@ 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) + }) + + it('leaves a customized v1 assistant untouched when OpenCode returns no converted agent', 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 }, + }]) + + expect(await convertAssistantOpenCodeConfigToV2(api)).toBe(false) + expect(await readFile(opencodeJsonPath, 'utf8')).toBe(v1Config) + }) + + it('leaves the file untouched when the converted config is invalid', 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: [{ action: 'shell', resource: '*', effect: 'sometimes' }], + agents: { assistant: { mode: 'primary', system: 'Converted prompt' } }, + }, + }]) + + expect(await convertAssistantOpenCodeConfigToV2(api)).toBe(false) + expect(await readFile(opencodeJsonPath, 'utf8')).toBe(v1Config) + }) +}) + 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..3de72b520 100644 --- a/backend/test/services/opencode/client.test.ts +++ b/backend/test/services/opencode/client.test.ts @@ -36,12 +36,17 @@ 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' describe('forwardRaw', () => { - it('should strip /api/opencode prefix from path, preserve search string, and strip host/connection/authorization headers', async () => { + it('should strip /api/opencode prefix from path, preserve search string, and strip host/connection/authorization/cookie headers', async () => { const mockResponse = new Response(JSON.stringify({}), { status: 200 }) let capturedUrl: URL | undefined let capturedInit: RequestInit | undefined @@ -58,6 +63,7 @@ describe('OpenCodeClient', () => { 'Host': 'localhost:5003', 'Connection': 'keep-alive', 'Authorization': 'Bearer token', + 'Cookie': 'session=manager', 'X-Custom-Header': 'value', }, }) @@ -73,6 +79,7 @@ describe('OpenCodeClient', () => { expect((capturedInit?.headers as Record)['host']).toBeUndefined() expect((capturedInit?.headers as Record)['connection']).toBeUndefined() expect((capturedInit?.headers as Record)['authorization']).toBeUndefined() + expect((capturedInit?.headers as Record)['cookie']).toBeUndefined() expect((capturedInit?.headers as Record)['x-opencode-directory']).toBe(encodeURIComponent('/test/workspace')) }) @@ -91,9 +98,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 +126,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 +185,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/backend/tsconfig.json b/backend/tsconfig.json index bfa0fead5..3077843cf 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -25,5 +25,6 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + "exclude": ["dist"] } 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..94d44f4df 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[] @@ -203,20 +191,12 @@ function buildPromptFields(input: PromptFields) { } } +/** + * Fetches one page of sessions for the given list options and normalizes the + * server cursor so the end of the list is `undefined` rather than null. + */ 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 } } @@ -543,10 +523,14 @@ export async function listPendingPermissions( return data } +/** + * Replies to a pending permission request with the user's decision, sending the + * optional message only when one is supplied. + */ export async function replyPermission( sessionID: string, requestID: string, - decision: 'once' | 'always' | 'reject', + decision: PermissionReply, message?: string, serverId?: string, ): Promise { @@ -581,16 +565,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..3f349eaf2 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, @@ -166,12 +163,16 @@ export async function toggleOpenCodeFavoriteModel(model: ModelSelection): Promis }); } +/** + * Returns the model's display name, falling back to its ID when the catalog + * provides no name. + */ 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..cbc7019dc 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,13 +29,19 @@ interface PermissionRequestDialogProps { onRespond: ( permissionID: string, sessionID: string, - response: PermissionResponse, + response: PermissionReply, message?: string, ) => Promise open?: boolean onOpenChange?: (open: boolean) => void } +/** + * Renders a modal prompt for the current pending permission request with + * allow-once, always-allow, and reject actions plus a count of further queued + * requests. Focus starts on allow-once, and the rejection reason resets + * whenever the request changes. + */ export function PermissionRequestDialog({ permission, pendingCount, @@ -49,7 +54,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 +66,11 @@ export function PermissionRequestDialog({ if (!permission) return null if (parentOpen === false) return null - const handleResponse = async (response: PermissionResponse) => { + /** + * Sends the user's reply for the current permission request, passing the trimmed rejection reason + * only for rejections, and reports failures as a toast while clearing the pending action state. + */ + 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..42ce0688d 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,343 @@ 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: '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: 'edit', resource: '*', effect: 'ask' }, + { action: 'edit', resource: 'src/**', effect: 'allow' }, + ]) + }) + + it('adds no wildcard rule for an unchanged action so it keeps inheriting the global permission', 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: 'src/**', effect: 'deny' }, + { action: 'shell', resource: 'git status', effect: 'ask' }, + ]) + }) + + it('prepends a wildcard rule only for an action set away from Default', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: 'Shell' })) + await user.click(screen.getByRole('option', { name: 'Deny' })) + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.permissions).toEqual([ + { action: 'shell', resource: '*', effect: 'deny' }, + { action: 'shell', resource: 'git status', effect: 'ask' }, + ]) + }) + + it('writes an explicit Allow over an inherited permission and removes the wildcard rule for Default', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + expect(screen.getByRole('combobox', { name: 'Shell' })).toHaveTextContent('Default') + + await user.click(screen.getByRole('combobox', { name: 'Shell' })) + await user.click(screen.getByRole('option', { name: 'Allow' })) + await user.click(screen.getByRole('combobox', { name: 'Edit' })) + await user.click(screen.getByRole('option', { name: 'Default' })) + 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: 'edit', resource: 'src/**', effect: 'allow' }, + ]) + }) + + it('places a new wildcard rule after a catch-all rule and before later exceptions', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: 'Shell' })) + await user.click(screen.getByRole('option', { name: 'Deny' })) + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.permissions).toEqual([ + { action: '*', resource: '*', effect: 'allow' }, + { action: 'shell', resource: '*', effect: 'deny' }, + { action: 'shell', resource: 'git status', effect: 'allow' }, + ]) + }) + + it('deletes the permissions key when every action returns to Default', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: 'Shell' })) + await user.click(screen.getByRole('option', { name: 'Default' })) + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.permissions).toBeNull() + }) + + it('deletes the request key when the last sampling value is cleared', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + await user.clear(screen.getByLabelText('Temperature')) + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.request).toBeNull() + }) + + it('writes no permissions for a prompt-only save of an agent without permission rules', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + await user.type(screen.getByLabelText('Prompt'), ' carefully') + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.system).toBe('Review code carefully') + expect(agent).not.toHaveProperty('permissions') + expect(agent).not.toHaveProperty('request') + }) + + it('leaves sampling values unset by default and removes a cleared stored value', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render( + , + ) + + expect(screen.getByLabelText('Top P')).toHaveValue(null) + + await user.clear(screen.getByLabelText('Temperature')) + await user.click(screen.getByRole('button', { name: 'Update' })) + + const agent = onSubmit.mock.calls[0][1] + expect(agent.request).toEqual({ body: { temperature: null, reasoning_effort: 'high' } }) + }) + + 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: '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..763f03a0f 100644 --- a/frontend/src/components/settings/AgentDialog.tsx +++ b/frontend/src/components/settings/AgentDialog.tsx @@ -11,79 +11,186 @@ 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'] | 'inherit' + +/** + * Reports whether a rule is a wildcard (`*` resource) rule for one of the + * managed actions (`edit`, `shell`, `webfetch`), which are the only rules the + * dialog's permission controls own. + */ +const isManagedWildcardRule = (rule: OpenCodePermissionRule): boolean => + rule.resource === '*' && MANAGED_PERMISSION_ACTIONS.includes(rule.action as ManagedPermissionAction) + +/** + * Returns the final rule accepted by `matches`, mirroring OpenCode's + * last-match-wins resolution, or `undefined` when no rule matches. + */ +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 +} + +/** + * Resolves the effect of an action's wildcard rule from stored permissions, + * returning `inherit` when the action has no such rule. + */ +const permissionEffect = ( + permissions: OpenCodePermissionRule[] | undefined, + action: ManagedPermissionAction, +): PermissionEffect => { + const rule = lastMatchingRule(permissions, (entry) => entry.action === action && entry.resource === '*') + return rule?.effect ?? 'inherit' +} + +/** + * Applies the form effects to the managed wildcard rules in place, so the rule + * order OpenCode resolves with last-match-wins stays unchanged. An `inherit` + * effect removes the action's wildcard rules so the global permission applies; + * any other effect adds a wildcard rule when the action has none yet, placed + * after the last catch-all `* *` rule so that rule cannot override it. + */ +const withManagedPermissionEffects = ( + permissions: OpenCodePermissionRule[] | undefined, + effects: Record, +): OpenCodePermissionRule[] => { + const updated = (permissions ?? []).flatMap((rule) => { + if (!isManagedWildcardRule(rule)) return [rule] + const effect = effects[rule.action as ManagedPermissionAction] + return effect === 'inherit' ? [] : [{ ...rule, effect }] + }) + const actionsWithWildcard = new Set(updated.filter(isManagedWildcardRule).map((rule) => rule.action)) + const fallbacks = MANAGED_PERMISSION_ACTIONS.flatMap((action) => { + const effect = effects[action] + return effect === 'inherit' || actionsWithWildcard.has(action) ? [] : [{ action, resource: '*', effect }] + }) + const fallbackIndex = updated.reduce( + (index, rule, currentIndex) => (rule.action === '*' && rule.resource === '*' ? currentIndex + 1 : index), + 0, + ) + return [...updated.slice(0, fallbackIndex), ...fallbacks, ...updated.slice(fallbackIndex)] +} + +/** + * Parses a text input into a number, yielding `undefined` for blank or + * unparsable input so a cleared form field stays unset. + */ +const optionalNumber = (value: string): number | undefined => { + const parsed = parseFloat(value) + return Number.isNaN(parsed) ? undefined : parsed +} + +/** + * Reads a numeric sampling value from the agent's raw `request.body`, returning + * `undefined` when the key is absent or holds a non-number. + */ +const requestBodyNumber = (agent: OpenCodeAgentConfig | undefined, key: string): number | undefined => { + const value = agent?.request?.body?.[key] + return typeof value === 'number' ? value : undefined +} + +/** + * Writes each sampling value the form sets into `request.body`. A cleared value + * is removed with a merge-patch `null` when the agent stored it and omitted + * otherwise, so the provider default applies. A request left with no fields is + * deleted instead of being saved as an empty object. + */ +const withSamplingValues = ( + previous: OpenCodeAgentConfig | undefined, + values: Record<'temperature' | 'top_p', number | undefined>, +): Pick => { + const previousBody = previous?.request?.body ?? {} + const body: NonNullable['body']> = { ...previousBody } + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) { + body[key] = value + } else if (key in previousBody) { + body[key] = null + } + } + const request = { ...previous?.request, body } + const bodyHasValues = Object.values(body).some((value) => value !== null && value !== undefined) + const requestHasOtherFields = Object.entries(request).some( + ([key, value]) => key !== 'body' && value !== undefined && value !== null, + ) + if (bodyHasValues || requestHasOtherFields) return { request } + return previous?.request ? { request: null } : {} +} 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'), description: z.string().optional(), prompt: z.string().min(1, 'Prompt is required'), mode: z.enum(['subagent', 'primary', 'all']), - temperature: z.number().min(0).max(2), - topP: z.number().min(0).max(1), + temperature: z.number().min(0).max(2).optional(), + topP: z.number().min(0).max(1).optional(), 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']), - webfetchPermission: z.enum(['ask', 'allow', 'deny']), - disable: z.boolean() + editPermission: z.enum(['inherit', 'ask', 'allow', 'deny']), + shellPermission: z.enum(['inherit', 'ask', 'allow', 'deny']), + webfetchPermission: z.enum(['inherit', 'ask', 'allow', 'deny']), + 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 +const formPermissionEffects = (values: AgentFormValues): Record => ({ + edit: values.editPermission, + shell: values.shellPermission, + webfetch: values.webfetchPermission, +}) 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 } +/** + * Renders the create/edit agent dialog with identity, prompt, mode, model, + * permission, sampling, and disabled fields, locking the name while editing an + * existing agent. Submitting merges the form into the stored agent so managed + * permission and sampling settings survive the patch. + */ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: AgentDialogProps) { - const getDefaultValues = (agent?: { name: string; agent: StoredAgent } | null): AgentFormValues => { + /** + * Builds the form's initial values for an existing agent, or empty defaults + * when none is supplied, mapping permissions to their winning wildcard rule. + */ + 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'), + topP: requestBodyNumber(agent?.agent, 'top_p'), 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 +200,17 @@ 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 permissions = withManagedPermissionEffects(previous?.permissions, formPermissionEffects(values)) + 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: { - edit: values.editPermission, - bash: values.bashPermission, - webfetch: values.webfetchPermission - } - } - - if (values.model?.trim()) { - agent.model = values.model.trim() + model: values.model?.trim() || null, + disabled: values.disable, + ...(permissions.length > 0 ? { permissions } : previous?.permissions ? { permissions: null } : {}), + ...withSamplingValues(previous, { temperature: values.temperature, top_p: values.topP }), } onSubmit(values.name, agent) @@ -234,7 +330,9 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen min="0" max="2" step="0.1" - onChange={(e) => field.onChange(parseFloat(e.target.value))} + placeholder="Default" + value={field.value ?? ''} + onChange={(e) => field.onChange(optionalNumber(e.target.value))} /> @@ -255,7 +353,9 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen min="0" max="1" step="0.1" - onChange={(e) => field.onChange(parseFloat(e.target.value))} + placeholder="Default" + value={field.value ?? ''} + onChange={(e) => field.onChange(optionalNumber(e.target.value))} /> @@ -287,75 +387,6 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen )} /> -
-
Tools Configuration
-
- ( - - - - - Write - - )} - /> - - ( - - - - - Edit - - )} - /> - - ( - - - - - Bash - - )} - /> - - ( - - - - - Web Fetch - - )} - /> -
-
-
Permissions
@@ -372,6 +403,7 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen + Default Ask Allow Deny @@ -384,10 +416,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 +261,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..bbce25877 100644 --- a/frontend/src/components/settings/CommandsEditor.tsx +++ b/frontend/src/components/settings/CommandsEditor.tsx @@ -1,58 +1,38 @@ 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) { +/** + * Renders configured commands alongside command files discovered in the workspace directory, + * providing add, edit and delete entry points against the managed config. + */ +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 +71,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..79635a40a 100644 --- a/frontend/src/components/settings/McpManager.tsx +++ b/frontend/src/components/settings/McpManager.tsx @@ -13,26 +13,21 @@ 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) { +/** + * Renders the MCP server list, unioning servers from the config with those known only to the live + * server status, and hosts the add, delete, OAuth and remove-auth dialogs. + */ +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 +45,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 +53,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 +71,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 +145,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..d70e1bbc7 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 @@ -34,6 +34,12 @@ interface OpenCodeConfigEditorProps { onUpdate: (request: { content: string; source: OpenCodeConfigSourceName; expectedRevision?: string }) => Promise } +/** + * Renders the raw OpenCode config editor dialog for the chosen config source, + * with find-in-text, inline validation issues, and save feedback. Closing with + * unsaved edits prompts for discard, and the source cannot be switched while + * the draft is dirty or saving. + */ export function OpenCodeConfigEditor({ config, isOpen, @@ -121,15 +127,17 @@ export function OpenCodeConfigEditor({ const getIssueText = (issue: ValidationIssue) => `${issue.path}: ${issue.message}` + /** + * Maps API validation issues to display entries, substituting a readable path + * for empty or dotted ones and resolving each to a best-effort source line in + * the current draft. + */ 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, @@ -138,19 +146,18 @@ export function OpenCodeConfigEditor({ } }) + /** + * Validates the draft as JSONC and saves it through `onUpdate` with the draft + * source and revision, closing the editor on success. Syntax errors, + * validation issues, and revision conflicts are shown in place without + * closing. + */ const updateConfig = async () => { if (!config || !draftSource) return 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..2db8c8d43 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(() => { @@ -280,6 +320,7 @@ describe('OpenCodeConfigManager', () => { const editIcon = container.querySelector('.lucide-square-pen') as SVGElement const editButton = editIcon.closest('button') as HTMLButtonElement await user.click(editButton) + const managedReadsBeforeSave = mockGetManagedOpenCodeConfig.mock.calls.length const textarea = await screen.findByLabelText('Config content') as HTMLTextAreaElement const next = configWithRaw.rawContent + ' ' @@ -296,6 +337,7 @@ describe('OpenCodeConfigManager', () => { const cached = queryClient.getQueryData(['opencode-config', 'file']) expect(cached?.revision).toBe('rev-2') expect(cached?.rawContent).toBe('{\n "theme": "dark"\n}') + await waitFor(() => expect(mockGetManagedOpenCodeConfig.mock.calls.length).toBeGreaterThan(managedReadsBeforeSave)) await waitFor(() => expect(screen.queryByText('Edit opencode.json')).not.toBeInTheDocument()) }) @@ -360,7 +402,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 +414,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 +436,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 +494,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 +646,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 +805,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..5c35fca8e 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,49 @@ 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 +} + +/** + * Renders the placeholder for a managed config section while its data is loading or unavailable, + * prioritising the loading state, then a 503 message, then a generic error. Returns `null` when + * none of the conditions apply. + */ +function ManagedConfigSectionFallback({ isLoading, unavailableMessage, errorMessage }: ManagedConfigSectionFallbackProps) { + if (isLoading) { + return ( +

+ + Loading configuration... +

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

{unavailableMessage}

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

OpenCode configuration unavailable

+

{errorMessage}

+
+ ) + } + + return null +} + +/** + * Renders the OpenCode settings panel whose collapsible sections manage + * AGENTS.md, commands, agents, skills, MCP servers, provider models, and host + * import. Config patches are revision-guarded, and a server restart is offered + * whenever saved changes require one. + */ export function OpenCodeConfigManager() { const sectionIdPrefix = useId() const agentsMdContentId = `${sectionIdPrefix}-agents-md` @@ -108,6 +125,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'], @@ -151,6 +178,10 @@ export function OpenCodeConfigManager() { const getApiErrorMessage = getOpenCodeApiErrorMessage + /** + * Returns the detail of a protected-host import failure, or the standard API + * error text with an import-specific fallback for other errors. + */ const getOpenCodeImportErrorMessage = (error: unknown): string => { if (error instanceof FetchError && error.code === 'OPENCODE_IMPORT_PROTECTED') { return error.detail || error.message @@ -159,31 +190,81 @@ 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 + + /** + * Builds the section meta label for a managed config collection, substituting loading and + * unavailable states for the configured item count. + */ + const getManagedConfigCountLabel = (count: number): string => { + if (isManagedConfigLoading) return 'Loading...' + if (managedConfigError) return 'Unavailable' + return `${count} configured` + } + + /** + * Returns the section content when managed config is settled, otherwise the shared loading or + * error fallback; the 503 message is only surfaced when the error is an unavailable response. + */ + const renderManagedConfigSection = (unavailableMessage: string, content: React.ReactNode) => + isManagedConfigPending ? ( + + ) : ( + content + ) + const applyOpenCodeConfigSave = (result: OpenCodeConfigSaveResponse) => { queryClient.setQueryData(OPEN_CODE_CONFIG_QUERY_KEY, result) + queryClient.invalidateQueries({ queryKey: OPEN_CODE_MANAGED_CONFIG_QUERY_KEY }) applyOpenCodeSaveFeedback({ appliedMessage: 'Configuration updated', restartRequired: result.restartRequired, }) } - 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) } - 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')) }) } + /** + * Persists an agent or command entry under `name`; when a rename occurred it also nulls the + * previous key so the old entry is removed in the same patch. Fire-and-forget, surfacing failures + * as a toast. + */ + 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 +446,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 +455,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 +479,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 +488,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 +539,7 @@ export function OpenCodeConfigManager() {

MCP Servers

- {Object.keys(mcpServersFromConfig(config.content.mcp)).length} configured + {getManagedConfigCountLabel(Object.keys(managedMcpServers).length)}
@@ -469,10 +548,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 +571,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 +585,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..518ec7d04 100644 --- a/frontend/src/components/settings/OpenCodeModelDialog.tsx +++ b/frontend/src/components/settings/OpenCodeModelDialog.tsx @@ -13,70 +13,133 @@ 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 + +/** + * Parses JSON text, returning `null` instead of throwing when the input is + * malformed. + */ +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 } } +/** + * Parses JSON text and returns it only when it is a plain object, so arrays, + * primitives, and malformed input all yield `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 +} + +/** + * Parses JSON text into an object or array, returning `null` for primitives and + * malformed input; callers that need one container kind narrow the result. + */ +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 +} + +/** + * Parses JSON text and returns it only when it is an array, so object-shaped or + * malformed input yields `null`. + */ +function parseJsonArray(value: string): unknown[] | null { + const parsed = parseJson(value) + return Array.isArray(parsed) ? parsed : null +} + +/** + * Serializes a value as indented JSON for an editable text field, returning an + * empty string for nullish input and for objects with no keys. + */ 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 { +/** + * Parses an optional JSON object field, treating blank input as absent and + * mapping invalid or non-object values to `undefined` so the patch omits them. + */ +function parseOptionalJsonField(value: string): JsonObject | undefined { if (!value.trim()) return undefined return parseJsonObject(value) ?? undefined } +/** + * Parses an optional field holding either a JSON object or array, returning + * `undefined` for blank, primitive, or malformed input. + */ +function parseOptionalJsonContainerField(value: string): JsonObject | unknown[] | undefined { + if (!value.trim()) return undefined + return parseJsonContainer(value) ?? undefined +} + +/** + * Parses an optional JSON array field, returning `undefined` when the field is + * blank or does not hold an array. + */ +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 } +/** + * Recursively diffs the previously stored model config against the next one and + * replaces each dropped key with a merge-patch `null` so the server removes it. + * Values are returned untouched unless both sides are objects. + */ +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, '-') } @@ -89,6 +152,11 @@ function prettifyModelName(modelId: string): string { .replace(/\b\w/g, (c) => c.toUpperCase()) } +/** + * Builds a schema field for optional JSON object text: blank input passes, and + * any other value must parse as a plain object or an issue named after `label` + * is reported. + */ function jsonObjectField(label: string) { return z.string().superRefine((value, ctx) => { if (!value.trim()) return @@ -101,32 +169,61 @@ function jsonObjectField(label: string) { }) } +/** + * Builds a form schema field that accepts blank input but rejects any non-blank + * value that is not a JSON object or array. + */ +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`, + }) + } + }) +} + +/** + * Builds a form schema field that accepts blank input but rejects any non-blank + * value that is not a valid JSON 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 +234,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,23 +262,29 @@ 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 } } +/** + * Renders the create/edit model dialog for a provider, including provider + * selection or new-provider fields, debounced model discovery from the provider + * base URL, limits, and free-form JSON fields for the remaining model settings. + * Values reset whenever the dialog opens for a different target. + */ export function OpenCodeModelDialog({ open, onOpenChange, @@ -200,21 +303,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 +322,7 @@ export function OpenCodeModelDialog({ newProviderId: '', newProviderName: '', newProviderBaseUrl: '', - newProviderNpm: '', + newProviderPackage: '', } } @@ -230,20 +330,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 +348,7 @@ export function OpenCodeModelDialog({ newProviderId: '', newProviderName: '', newProviderBaseUrl: '', - newProviderNpm: '', + newProviderPackage: '', } }, [editingModel, selectedProviderId, availableProviders]) @@ -280,9 +377,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) => { @@ -351,45 +447,48 @@ export function OpenCodeModelDialog({ } }, [open, resetKey, form, getDefaultValues]) + /** + * Converts the validated form values into a model patch, parsing optional JSON + * and numeric fields and nulling keys cleared while editing, then passes the + * patch and any new provider config to `onSubmit` before resetting and closing. + */ 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 +497,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 +615,7 @@ export function OpenCodeModelDialog({ )} {newProviderType === 'npm' && ( - ( + ( NPM Package @@ -596,7 +695,7 @@ export function OpenCodeModelDialog({ )} /> -
+
( Display Name @@ -612,64 +711,23 @@ export function OpenCodeModelDialog({ )} /> -
- -
- ( - - Status - - - - )} /> - ( + ( - Release Date - + Package + )} />
-
- ( - - Model Provider ID - - - - )} /> - -
- ( - - API URL - - - - )} /> - - ( - - API NPM - - - - )} /> -
-
+ ( + + Release Date + + + + )} />
( @@ -700,9 +758,9 @@ export function OpenCodeModelDialog({

Structured JSON fields

- ( + ( - Options JSON + Settings JSON