diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index d97922290..0983f1b82 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -3,6 +3,9 @@ import type { ZodType } from "zod" export namespace BusEvent { export type Definition = ReturnType + type PayloadOptions = { + include?: Iterable + } const registry = new Map() @@ -15,24 +18,30 @@ export namespace BusEvent { return result } - export function payloads() { + function payloadEntries(options?: PayloadOptions) { + if (!options?.include) return registry.entries().toArray() + + return Array.from(options.include, (type) => { + const def = registry.get(type) + if (!def) throw new Error(`Bus event schema is not registered: ${type}`) + return [type, def] as const + }) + } + + export function payloads(options?: PayloadOptions) { + const schemas = payloadEntries(options).map(([type, def]) => { + return z + .object({ + type: z.literal(type), + properties: def.properties, + }) + .meta({ + ref: "Event" + "." + def.type, + }) + }) + return z - .discriminatedUnion( - "type", - registry - .entries() - .map(([type, def]) => { - return z - .object({ - type: z.literal(type), - properties: def.properties, - }) - .meta({ - ref: "Event" + "." + def.type, - }) - }) - .toArray() as any, - ) + .discriminatedUnion("type", schemas as any) .meta({ ref: "Event", }) diff --git a/packages/opencode/src/server/control-openapi.ts b/packages/opencode/src/server/control-openapi.ts index 17ad4e48a..41a566077 100644 --- a/packages/opencode/src/server/control-openapi.ts +++ b/packages/opencode/src/server/control-openapi.ts @@ -1,9 +1,12 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { OpenApi } from "effect/unstable/httpapi" import { resolver } from "hono-openapi" +import { BusEvent } from "@/bus/bus-event" +import { Info as ConfigInfo } from "@/config/config" +import { PtyID } from "@/pty/schema" import { BadRequestErrorSchema } from "./error" import { globalEventOpenApiSchema, globalSyncEventOpenApiSchema } from "./global-openapi-schema" -import { ControlApi } from "./routes/instance/httpapi/groups/control" -import { GlobalApi } from "./routes/instance/httpapi/groups/global" +import { ProductionApi } from "./production-api" +import { productionBusEventTypes, productionSyncEventTypes } from "./production-event-sources" type OpenApiDocument = { openapi?: string @@ -18,8 +21,6 @@ type OpenApiDocument = { } } -const ControlDocApi = HttpApi.make("controlDoc").addHttpApi(ControlApi).addHttpApi(GlobalApi) - function mergeSchemas(document: OpenApiDocument, schemas: Record, options?: { override?: boolean }) { document.components ??= {} document.components.schemas = options?.override @@ -33,12 +34,52 @@ function mergeSchemas(document: OpenApiDocument, schemas: Record + const anyOf = record.anyOf + if ( + Array.isArray(anyOf) && + anyOf.every((item) => item && typeof item === "object" && typeof (item as Record).$ref === "string") + ) { + anyOf.sort((left, right) => + String((left as Record).$ref).localeCompare(String((right as Record).$ref)), + ) + } + for (const item of Object.values(record)) sortRefUnions(item) +} + +const workspaceRoutingParameters = [ + { + in: "query", + name: "directory", + schema: { + type: "string", + }, + }, + { + in: "query", + name: "workspace", + schema: { + type: "string", + }, + }, +] + export async function controlOpenApi() { - const document = structuredClone(OpenApi.fromApi(ControlDocApi) as OpenApiDocument) - const [globalEvent, globalSyncEvent, badRequest] = await Promise.all([ - resolver(globalEventOpenApiSchema()).toOpenAPISchema(), - resolver(globalSyncEventOpenApiSchema()).toOpenAPISchema(), + const document = structuredClone(OpenApi.fromApi(ProductionApi) as OpenApiDocument) + const [instanceEvent, globalEvent, globalSyncEvent, badRequest, config, ptyID] = await Promise.all([ + resolver(BusEvent.payloads({ include: productionBusEventTypes })).toOpenAPISchema(), + resolver(globalEventOpenApiSchema({ busEventTypes: productionBusEventTypes })).toOpenAPISchema(), + resolver(globalSyncEventOpenApiSchema({ syncEventTypes: productionSyncEventTypes })).toOpenAPISchema(), resolver(BadRequestErrorSchema).toOpenAPISchema(), + resolver(ConfigInfo.zod).toOpenAPISchema(), + resolver(PtyID.zod).toOpenAPISchema(), ]) document.openapi = "3.1.1" @@ -49,6 +90,24 @@ export async function controlOpenApi() { } document.paths ??= {} delete document.paths["/doc"] + document.paths["/event"] = { + get: { + operationId: "event.subscribe", + summary: "Subscribe to events", + description: "Get events", + parameters: workspaceRoutingParameters, + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: instanceEvent.schema, + }, + }, + }, + }, + }, + } document.paths["/global/event"] = { get: { operationId: "global.event", @@ -83,8 +142,57 @@ export async function controlOpenApi() { }, }, } + document.paths["/pty/{ptyID}/connect"] = { + get: { + operationId: "pty.connect", + summary: "Connect to PTY session", + description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + parameters: [ + ...workspaceRoutingParameters, + { + in: "path", + name: "ptyID", + schema: ptyID.schema, + required: true, + }, + { + in: "query", + name: "cursor", + schema: { + type: "string", + }, + }, + { + in: "query", + name: "ticket", + schema: { + type: "string", + }, + }, + ], + responses: { + 101: { + description: "WebSocket protocol upgrade", + }, + 404: { + description: "Not found", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/NotFoundError", + }, + }, + }, + }, + }, + }, + } + mergeSchemas(document, instanceEvent.components?.schemas ?? {}) mergeSchemas(document, globalEvent.components?.schemas ?? {}) mergeSchemas(document, globalSyncEvent.components?.schemas ?? {}) + mergeSchemas(document, ptyID.components?.schemas ?? {}) mergeSchemas(document, badRequest.components?.schemas ?? {}, { override: true }) + mergeSchemas(document, config.components?.schemas ?? {}, { override: true }) + sortRefUnions(document) return document } diff --git a/packages/opencode/src/server/global-openapi-schema.ts b/packages/opencode/src/server/global-openapi-schema.ts index 25ca9ff7f..706d034f6 100644 --- a/packages/opencode/src/server/global-openapi-schema.ts +++ b/packages/opencode/src/server/global-openapi-schema.ts @@ -2,23 +2,23 @@ import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import z from "zod" -export function globalEventOpenApiSchema() { +export function globalEventOpenApiSchema(options?: { busEventTypes?: Iterable }) { return z .object({ directory: z.string(), project: z.string().optional(), workspace: z.string().optional(), - payload: BusEvent.payloads(), + payload: BusEvent.payloads({ include: options?.busEventTypes }), }) .meta({ ref: "GlobalEvent", }) } -export function globalSyncEventOpenApiSchema() { +export function globalSyncEventOpenApiSchema(options?: { syncEventTypes?: Iterable }) { return z .object({ - payload: SyncEvent.payloads(), + payload: SyncEvent.payloads({ include: options?.syncEventTypes }), }) .meta({ ref: "SyncEvent", diff --git a/packages/opencode/src/server/production-api.ts b/packages/opencode/src/server/production-api.ts new file mode 100644 index 000000000..640c4a1b1 --- /dev/null +++ b/packages/opencode/src/server/production-api.ts @@ -0,0 +1,35 @@ +import { HttpApi } from "effect/unstable/httpapi" +import { AutomationApi } from "./routes/instance/httpapi/groups/automation" +import { ConfigApi } from "./routes/instance/httpapi/groups/config" +import { ControlApi } from "./routes/instance/httpapi/groups/control" +import { ExperimentalApi } from "./routes/instance/httpapi/groups/experimental" +import { ExternalResultApi } from "./routes/instance/httpapi/groups/external-result" +import { FileApi } from "./routes/instance/httpapi/groups/file" +import { GlobalApi } from "./routes/instance/httpapi/groups/global" +import { McpApi } from "./routes/instance/httpapi/groups/mcp" +import { MemoryApi } from "./routes/instance/httpapi/groups/memory" +import { PermissionApi } from "./routes/instance/httpapi/groups/permission" +import { ProjectApi } from "./routes/instance/httpapi/groups/project" +import { ProviderApi } from "./routes/instance/httpapi/groups/provider" +import { PtyApi } from "./routes/instance/httpapi/groups/pty" +import { RootApi } from "./routes/instance/httpapi/groups/root" +import { SessionApi } from "./routes/instance/httpapi/groups/session" +import { WorkspaceApi } from "./routes/instance/httpapi/groups/workspace" + +export const ProductionApi = HttpApi.make("production") + .addHttpApi(ControlApi) + .addHttpApi(GlobalApi) + .addHttpApi(WorkspaceApi) + .addHttpApi(RootApi) + .addHttpApi(ProjectApi) + .addHttpApi(PtyApi) + .addHttpApi(ConfigApi) + .addHttpApi(ExperimentalApi) + .addHttpApi(SessionApi) + .addHttpApi(PermissionApi) + .addHttpApi(ExternalResultApi) + .addHttpApi(ProviderApi) + .addHttpApi(MemoryApi) + .addHttpApi(AutomationApi) + .addHttpApi(FileApi) + .addHttpApi(McpApi) diff --git a/packages/opencode/src/server/production-event-sources.ts b/packages/opencode/src/server/production-event-sources.ts new file mode 100644 index 000000000..bebb265c6 --- /dev/null +++ b/packages/opencode/src/server/production-event-sources.ts @@ -0,0 +1,82 @@ +// Keep /doc event schemas aligned with the production server modules that define SSE payloads. +import "@/automation" +import "@/bus" +import "@/command" +import "@/control-plane/workspace" +import "@/file" +import "@/file/watcher" +import "@/installation" +import "@/lsp" +import "@/lsp/client" +import "@/mcp" +import "@/permission" +import "@/project/project" +import "@/project/vcs" +import "@/pty" +import "@/server/event" +// Installs the latest SyncEvent definitions onto BusEvent for production SSE schemas. +import "@/server/projectors" +import "@/session/compaction" +import "@/session/message-v2" +import "@/session/session" +import "@/session/status" +import "@/session/todo" +import "@/worktree" + +export const productionBusEventTypes = [ + "automation.definition.deleted", + "automation.definition.updated", + "automation.run.updated", + "command.executed", + "file.edited", + "file.watcher.rescan", + "file.watcher.updated", + "global.disposed", + "installation.update-available", + "installation.updated", + "lsp.client.diagnostics", + "lsp.server.install.failed", + "lsp.updated", + "mcp.browser.open.failed", + "mcp.tools.changed", + "message.part.delta", + "message.part.removed", + "message.part.updated", + "message.removed", + "message.updated", + "permission.asked", + "permission.replied", + "project.updated", + "pty.created", + "pty.deleted", + "pty.exited", + "pty.updated", + "server.connected", + "server.instance.disposed", + "session.compacted", + "session.created", + "session.deleted", + "session.diff", + "session.error", + "session.idle", + "session.status", + "session.turn_change_invalidated", + "session.updated", + "todo.updated", + "vcs.branch.updated", + "workspace.failed", + "workspace.ready", + "workspace.status", + "worktree.failed", + "worktree.ready", +] as const + +export const productionSyncEventTypes = [ + "message.part.removed", + "message.part.updated", + "message.removed", + "message.updated", + "session.created", + "session.deleted", + "session.updated", +] as const diff --git a/packages/opencode/src/server/production-httpapi.ts b/packages/opencode/src/server/production-httpapi.ts index e0ac34fdf..fe1a11ca8 100644 --- a/packages/opencode/src/server/production-httpapi.ts +++ b/packages/opencode/src/server/production-httpapi.ts @@ -6,7 +6,7 @@ import { NodeFileSystem, NodeHttpPlatform, NodePath } from "@effect/platform-nod import { Runtime } from "@opencode-ai/core/runtime" import { Context, Effect, Layer } from "effect" import { Etag, HttpEffect, HttpServerRequest, HttpServerResponse, HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { WorkspaceContext } from "@/control-plane/workspace-context" import type { WorkspaceID } from "@/control-plane/schema" import { AppRuntime } from "@/effect/app-runtime" @@ -23,6 +23,7 @@ import { type ProductionSpecialHandler, } from "./production-special" import { requestContextFromRequest, withRequestContext } from "./request-context" +import { ProductionApi } from "./production-api" import { automationHandlers } from "./routes/instance/httpapi/handlers/automation" import { configHandlers } from "./routes/instance/httpapi/handlers/config" import { controlHandlers } from "./routes/instance/httpapi/handlers/control" @@ -39,40 +40,6 @@ import { ptyHandlers } from "./routes/instance/httpapi/handlers/pty" import { rootHandlers } from "./routes/instance/httpapi/handlers/root" import { sessionHandlers } from "./routes/instance/httpapi/handlers/session" import { workspaceHandlers } from "./routes/instance/httpapi/handlers/workspace" -import { AutomationApi } from "./routes/instance/httpapi/groups/automation" -import { ConfigApi } from "./routes/instance/httpapi/groups/config" -import { ControlApi } from "./routes/instance/httpapi/groups/control" -import { ExperimentalApi } from "./routes/instance/httpapi/groups/experimental" -import { ExternalResultApi } from "./routes/instance/httpapi/groups/external-result" -import { FileApi } from "./routes/instance/httpapi/groups/file" -import { GlobalApi } from "./routes/instance/httpapi/groups/global" -import { McpApi } from "./routes/instance/httpapi/groups/mcp" -import { MemoryApi } from "./routes/instance/httpapi/groups/memory" -import { PermissionApi } from "./routes/instance/httpapi/groups/permission" -import { ProjectApi } from "./routes/instance/httpapi/groups/project" -import { ProviderApi } from "./routes/instance/httpapi/groups/provider" -import { PtyApi } from "./routes/instance/httpapi/groups/pty" -import { RootApi } from "./routes/instance/httpapi/groups/root" -import { SessionApi } from "./routes/instance/httpapi/groups/session" -import { WorkspaceApi } from "./routes/instance/httpapi/groups/workspace" - -const ProductionApi = HttpApi.make("production") - .addHttpApi(ControlApi) - .addHttpApi(GlobalApi) - .addHttpApi(WorkspaceApi) - .addHttpApi(RootApi) - .addHttpApi(ProjectApi) - .addHttpApi(PtyApi) - .addHttpApi(ConfigApi) - .addHttpApi(ExperimentalApi) - .addHttpApi(SessionApi) - .addHttpApi(PermissionApi) - .addHttpApi(ExternalResultApi) - .addHttpApi(ProviderApi) - .addHttpApi(MemoryApi) - .addHttpApi(AutomationApi) - .addHttpApi(FileApi) - .addHttpApi(McpApi) const productionHandlers = Layer.mergeAll( controlHandlers, diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 95074b7fc..96b678104 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -45,6 +45,9 @@ export namespace SyncEvent { } export type SerializedEvent = Event & { type: string } + type PayloadOptions = { + include?: Iterable + } type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void export interface Interface { @@ -296,24 +299,33 @@ export namespace SyncEvent { return () => Bus.off("event", handler) } - export function payloads() { + function payloadEntries(options?: PayloadOptions) { + if (!options?.include) return registry.entries().toArray() + + return Array.from(options.include, (type) => { + const version = versions.get(type) + const schemaType = version === undefined ? undefined : versionedType(type, version) + const def = schemaType === undefined ? undefined : registry.get(schemaType) + if (!def) throw new Error(`Sync event schema is not registered: ${type}`) + return [schemaType, def] as const + }) + } + + export function payloads(options?: PayloadOptions) { + const schemas = payloadEntries(options).map(([type, def]) => { + return z + .object({ + type: z.literal(type), + aggregate: z.literal(def.aggregate), + data: def.schema, + }) + .meta({ + ref: "SyncEvent" + "." + def.type, + }) + }) + return z - .union( - registry - .entries() - .map(([type, def]) => { - return z - .object({ - type: z.literal(type), - aggregate: z.literal(def.aggregate), - data: def.schema, - }) - .meta({ - ref: "SyncEvent" + "." + def.type, - }) - }) - .toArray() as any, - ) + .union(schemas as any) .meta({ ref: "SyncEvent", }) diff --git a/packages/opencode/test/server/openapi-generation-source.test.ts b/packages/opencode/test/server/openapi-generation-source.test.ts new file mode 100644 index 000000000..2de845adb --- /dev/null +++ b/packages/opencode/test/server/openapi-generation-source.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { controlOpenApi } from "../../src/server/control-openapi" + +const expectedProductionEventSchemaRefs = [ + "Event.automation.definition.deleted", + "Event.automation.definition.updated", + "Event.automation.run.updated", + "Event.command.executed", + "Event.file.edited", + "Event.file.watcher.rescan", + "Event.file.watcher.updated", + "Event.global.disposed", + "Event.installation.update-available", + "Event.installation.updated", + "Event.lsp.client.diagnostics", + "Event.lsp.server.install.failed", + "Event.lsp.updated", + "Event.mcp.browser.open.failed", + "Event.mcp.tools.changed", + "Event.message.part.delta", + "Event.message.part.removed", + "Event.message.part.updated", + "Event.message.removed", + "Event.message.updated", + "Event.permission.asked", + "Event.permission.replied", + "Event.project.updated", + "Event.pty.created", + "Event.pty.deleted", + "Event.pty.exited", + "Event.pty.updated", + "Event.server.connected", + "Event.server.instance.disposed", + "Event.session.compacted", + "Event.session.created", + "Event.session.deleted", + "Event.session.diff", + "Event.session.error", + "Event.session.idle", + "Event.session.status", + "Event.session.turn_change_invalidated", + "Event.session.updated", + "Event.todo.updated", + "Event.vcs.branch.updated", + "Event.workspace.failed", + "Event.workspace.ready", + "Event.workspace.status", + "Event.worktree.failed", + "Event.worktree.ready", +] + +const expectedProductionSyncEventSchemaRefs = [ + "SyncEvent.message.part.removed", + "SyncEvent.message.part.updated", + "SyncEvent.message.removed", + "SyncEvent.message.updated", + "SyncEvent.session.created", + "SyncEvent.session.deleted", + "SyncEvent.session.updated", +] + +function eventSchemaRefs(spec: Awaited>) { + const eventSchema = spec.components?.schemas?.Event as { anyOf?: Array<{ $ref?: string }> } | undefined + return (eventSchema?.anyOf ?? []) + .map((item) => item.$ref?.replace("#/components/schemas/", "")) + .filter((item): item is string => Boolean(item)) +} + +function syncEventSchemaRefs(spec: Awaited>) { + return Object.keys(spec.components?.schemas ?? {}) + .filter((name) => name.startsWith("SyncEvent.")) + .sort() +} + +function generateAfterIsolatedEventLeak() { + const script = ` + import z from "zod" + const { BusEvent } = await import("./src/bus/bus-event.ts") + const { controlOpenApi } = await import("./src/server/control-openapi.ts") + + BusEvent.define("test.openapi.leak", z.object({ value: z.number() })) + const spec = await controlOpenApi() + const eventRefs = (spec.components?.schemas?.Event?.anyOf ?? []) + .map((item) => item.$ref?.replace("#/components/schemas/", "")) + .filter(Boolean) + const syncRefs = Object.keys(spec.components?.schemas ?? {}) + .filter((name) => name.startsWith("SyncEvent.")) + .sort() + + console.log(JSON.stringify({ eventRefs, syncRefs })) + ` + const result = Bun.spawnSync({ + cmd: [process.execPath, "--eval", script], + cwd: path.join(import.meta.dir, "..", ".."), + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }) + + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString()) + return JSON.parse(Buffer.from(result.stdout).toString()) as { eventRefs: string[]; syncRefs: string[] } +} + +describe("OpenAPI generation source", () => { + test("reuses the shared ProductionApi for dispatch and documentation", async () => { + const controlOpenApiSource = await readFile(path.join(import.meta.dir, "../../src/server/control-openapi.ts"), "utf8") + const productionHttpApiSource = await readFile( + path.join(import.meta.dir, "../../src/server/production-httpapi.ts"), + "utf8", + ) + + expect(controlOpenApiSource).toContain('import { ProductionApi } from "./production-api"') + expect(productionHttpApiSource).toContain('import { ProductionApi } from "./production-api"') + expect(productionHttpApiSource).not.toContain('HttpApi.make("production")') + }) + + test("generates the production Effect HttpApi document directly", async () => { + const spec = await controlOpenApi() + + expect(spec.openapi).toBe("3.1.1") + expect(spec.paths).not.toHaveProperty("/doc") + expect(spec.paths).toHaveProperty("/event") + expect(spec.paths).toHaveProperty("/global/sync-event") + expect(spec.paths).toHaveProperty("/pty/{ptyID}/connect") + expect(spec.paths).toHaveProperty("/automation/{automationID}/run") + expect(spec.paths).toHaveProperty("/memory/entry/{id}") + expect(spec.paths).toHaveProperty("/session/{sessionID}/tool/respond") + expect(spec.paths).toHaveProperty("/provider/recent") + expect(spec.paths).not.toHaveProperty("/question") + + const ptyConnectOperation = spec.paths?.["/pty/{ptyID}/connect"] as + | { get?: { responses?: Record } } + | undefined + const ptyConnectResponses = ptyConnectOperation?.get?.responses ?? {} + expect(ptyConnectResponses).toHaveProperty("101") + expect(ptyConnectResponses).not.toHaveProperty("200") + expect(ptyConnectResponses["101"]).not.toHaveProperty("content") + }) + + test("generates the full production event schema without import-order dependence", async () => { + const spec = await controlOpenApi() + const isolatedLeakSpec = generateAfterIsolatedEventLeak() + + expect(eventSchemaRefs(spec)).toEqual(expectedProductionEventSchemaRefs) + expect(syncEventSchemaRefs(spec)).toEqual(expectedProductionSyncEventSchemaRefs) + expect(isolatedLeakSpec.eventRefs).toEqual(expectedProductionEventSchemaRefs) + expect(isolatedLeakSpec.syncRefs).toEqual(expectedProductionSyncEventSchemaRefs) + }) + + test("preserves config zod override schemas in the production document", async () => { + const spec = await controlOpenApi() + const schemas = spec.components?.schemas ?? {} + + expect(schemas).toHaveProperty("AgentConfig") + expect(JSON.stringify(schemas.Config)).toContain("#/components/schemas/AgentConfig") + }) + + test("documents the remaining checked-in SDK OpenAPI drift from the production source", async () => { + const checkedIn = JSON.parse(await readFile(path.join(import.meta.dir, "../../../sdk/openapi.json"), "utf8")) + const production = await controlOpenApi() + const checkedInPaths = new Set(Object.keys(checkedIn.paths ?? {})) + const productionPaths = new Set(Object.keys(production.paths ?? {})) + + expect([...productionPaths].filter((routePath) => !checkedInPaths.has(routePath)).sort()).toEqual([ + "/automation", + "/automation/{automationID}", + "/automation/{automationID}/pause", + "/automation/{automationID}/resume", + "/automation/{automationID}/run", + "/automation/{automationID}/runs", + "/external-result", + "/memory", + "/memory/disabled", + "/memory/entry/{id}", + "/memory/reset", + "/permission/__e2e/ask", + "/provider/recent", + "/session/__e2e/update-todos", + "/session/{sessionID}/tool/respond", + "/session/{sessionID}/turn-change/{messageID}", + "/session/{sessionID}/turn-change/{messageID}/redo", + "/session/{sessionID}/turn-change/{messageID}/undo", + "/session/{sessionID}/turn/{userMessageID}/changes", + "/session/{sessionID}/turn/{userMessageID}/changes/redo", + "/session/{sessionID}/turn/{userMessageID}/changes/undo", + "/vcs/apply", + "/vcs/diff/raw", + "/vcs/status", + ]) + expect([...checkedInPaths].filter((routePath) => !productionPaths.has(routePath)).sort()).toEqual([]) + }) +})