From 44579a5d98c82a7ce685aec3bfd503dd77894cee Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:32:34 +1000 Subject: [PATCH] test(app): mock session wait and parse e2e mock payloads (#53095) --- packages/app/e2e/utils/mock-api.ts | 44 +- packages/app/e2e/utils/mock-server.ts | 751 ++++++++++++++++---------- 2 files changed, 497 insertions(+), 298 deletions(-) diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index 665c3009c01..3faabb4e6c3 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -1,16 +1,31 @@ -import { Schema, SchemaGetter } from "effect" +import { Predicate, Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { Pty } from "@opencode/schema/pty" import { Worktree } from "@opencode/schema/worktree" +// Handlers answer plain fixture data: undefined properties are dropped, and other non-JSON values become null. const Json = Schema.Json.pipe( Schema.decodeTo(Schema.Unknown, { decode: SchemaGetter.passthrough(), - encode: SchemaGetter.transform(jsonValue), + encode: SchemaGetter.transform(function json(value): Schema.Json { + if (value === null || Predicate.isString(value) || Predicate.isBoolean(value)) return value + + if (Predicate.isNumber(value)) return Number.isFinite(value) ? value : null + + if (Array.isArray(value)) return value.map(json) + + if (!Predicate.isObject(value)) return null + + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, json(item)]])), + ) + }), }), HttpApiSchema.asJson(), ) + const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson()) + const Query = Schema.Struct({ directory: Schema.optional(Schema.String), parentID: Schema.optional(Schema.String), @@ -23,8 +38,11 @@ const Query = Schema.Struct({ type: Schema.optional(Schema.String), mode: Schema.optional(Schema.String), }) + const SessionParams = { sessionID: Schema.String } + const PtyParams = { ptyID: Pty.ID } + const NoContent = HttpApiSchema.NoContent export class MockNotFound extends Schema.TaggedError()("MockNotFound", { @@ -45,10 +63,16 @@ export class MockShellNotFound extends Schema.TaggedError()(" message: Schema.String, }) {} +// The server's error for a request without its password. +export class MockUnauthorized extends Schema.TaggedError()("UnauthorizedError", { + message: Schema.String, +}) {} + // A mutation the scenario did not configure a handler for. export class MockUnsupported extends Schema.TaggedError()("MockUnsupported", { message: Schema.String, }) {} + const Unsupported = MockUnsupported.pipe(HttpApiSchema.status(501)) const Group = HttpApiGroup.make("mock") @@ -368,6 +392,12 @@ const Group = HttpApiGroup.make("mock") ) .add( HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", { + params: SessionParams, + success: Json, + }), + ) + .add( + HttpApiEndpoint.post("sessionWait", "/api/experimental/session/:sessionID/wait", { params: SessionParams, success: NoContent, }), @@ -409,13 +439,3 @@ const Group = HttpApiGroup.make("mock") ) export const MockApi = HttpApi.make("mock").add(Group) - -function jsonValue(value: unknown): Schema.Json { - if (value === null || typeof value === "string" || typeof value === "boolean") return value - if (typeof value === "number") return Number.isFinite(value) ? value : null - if (Array.isArray(value)) return value.map(jsonValue) - if (!value || typeof value !== "object") return null - return Object.fromEntries( - Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, jsonValue(item)]])), - ) -} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 4777c9d12f0..c24388b1316 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,10 +1,20 @@ import type { Page } from "@playwright/test" -import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise" -import { Duration, Effect, Layer } from "effect" +import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise" +import { Permission } from "@opencode/schema/permission" +import { Worktree } from "@opencode/schema/worktree" +import { Duration, Effect, Layer, Option, Predicate, Schema } from "effect" import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { SERVER } from "./app" -import { MockApi, MockBadRequest, MockInternal, MockNotFound, MockShellNotFound, MockUnsupported } from "./mock-api" +import { + MockApi, + MockBadRequest, + MockInternal, + MockNotFound, + MockShellNotFound, + MockUnauthorized, + MockUnsupported, +} from "./mock-api" import { installSseTransport } from "./sse-transport" type Resolvable = T | (() => T) @@ -12,9 +22,27 @@ type Resolvable = T | (() => T) // A hook's replacement response. export type MockAnswer = { status: number; body: unknown } +// The legacy `/provider` catalog: `all` lists providers with `id`, `name` and `models` keyed by model ID. +export type MockProviderCatalog = { + all?: readonly unknown[] + connected?: readonly string[] + default?: { providerID?: string; modelID?: string } +} + +// A session in any shape a scenario seeds, current or legacy (`directory`, `path`); `currentSession` reads it. +// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- scenarios seed arbitrary session fields +export type MockSession = { id: string } & Record + +export type MockMcpStatus = { status: string; error?: string } + +export type MockFileContent = + | string + | { type?: string; content: string; encoding?: string; mimeType?: string } + | undefined + export interface MockServerConfig { server?: string - provider: unknown | (() => unknown) + provider: Resolvable integrations?: unknown[] onConnectKey?: (input: { integrationID: string; body: unknown }) => void // Terminal shells the settings offer (`/api/config/shell`). @@ -33,7 +61,7 @@ export interface MockServerConfig { project: unknown // Replaces the `/api/project` inventory, which defaults to `[project]`. projects?: Resolvable - sessions: ({ id: string } & Record)[] + sessions: MockSession[] pageMessages: ( sessionId: string, limit: number, @@ -70,11 +98,7 @@ export interface MockServerConfig { mcp?: unknown[] | ((directory: string) => unknown[]) // Connect/disconnect record the server's new status in that workspace (`connected`/`disabled`); the hook may return // another status (for example `{ status: "failed", error }`). Unknown servers answer 404. - onMcpAction?: (input: { - server: string - action: "connect" | "disconnect" - directory: string - }) => void | Record + onMcpAction?: (input: { server: string; action: "connect" | "disconnect"; directory: string }) => void | MockMcpStatus // Starts an OAuth attempt (POST .../connect/oauth) and returns its authorization URL; the attempt then stays pending. // Without it, OAuth connects answer 501 MockUnsupported. onIntegrationOAuth?: (input: { integrationID: string; directory: string; body: unknown }) => { url: string } @@ -84,22 +108,23 @@ export interface MockServerConfig { worktrees?: Resolvable // Without them, creating or removing a worktree answers 501 MockUnsupported. `onWorktreeCreate` may hold the request // and return the answer; by default it creates `/`. A created directory joins the project's sandboxes. - onWorktreeCreate?: (input: unknown) => void | MockAnswer | Promise - onWorktreeRemove?: (input: unknown) => void | Promise + onWorktreeCreate?: (input: Schema.Json) => void | MockAnswer | Promise + onWorktreeRemove?: (input: Worktree.RemoveInput) => void | Promise // POST /api/session keeps the client-reserved `id` and `location`. Return an answer to fail the attempt (1-based). - onSessionCreate?: (body: Record, attempt: number) => void | MockAnswer + onSessionCreate?: (body: Schema.JsonObject, attempt: number) => void | MockAnswer // Title of created sessions (default: the request's title, else "New session"). createdSessionTitle?: string // Slash commands served by `/api/command`. commands?: Resolvable // Records POST /api/session/:id/command (204). Without it, commands answer 501 MockUnsupported. onCommand?: (input: { sessionID: string; body: unknown }) => void - fileList?: (path: string) => unknown | Promise - fileContent?: (path: string) => unknown | Promise - findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown - sessionStatus?: Record | (() => Record) + fileList?: (path: string) => unknown[] | Promise + fileContent?: (path: string) => MockFileContent | Promise + // Paths become directory entries. + findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown[] + sessionStatus?: Resolvable> inbox?: unknown[] | (() => unknown[]) - onPrompt?: (input: { sessionID: string; body: Record }) => void + onPrompt?: (input: { sessionID: string; body: Schema.JsonObject }) => void generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }> onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void // Serves `/api/pty*` and mock PTY WebSockets. Created IDs are the first unused `${prefix}` (prefix must start with "pty"). @@ -136,13 +161,23 @@ export type MockPty = { send(data: string, id?: string): void } -type MockStream = { push: (payloads: unknown[]) => void } +// The page relays events as opaque values; benchmarks also push their own payloads. +type MockStream = { push: (payloads: readonly unknown[]) => void } + +type MockStreamState = { + controller?: ReadableStreamDefaultController + buffer: string[] + connections: number +} + +// The `installSseTransport` command that delivers events. +type MockSendCommand = { type: "send"; deliveries: { payload: unknown }[]; burst: boolean } type MockStreamWindow = Window & { // Set to any value by benchmarks that bring their own event stream. __testSseTransport?: unknown - // `installSseTransport` registrations; `command` takes its browser command shape. - __testSseTransports?: Record unknown }> + // `installSseTransport` registrations. + __testSseTransports?: Record void }> // Per-origin mock event streams; in-page benchmark probes push through them directly. __mockServerStreams?: Record } @@ -154,46 +189,56 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { await page.addInitScript( ({ server, retry, keepalive: idle }) => { + // SAFETY: only this script, `installSseTransport`, and benchmarks write these globals, in the declared shapes. const host = window as MockStreamWindow + if (host.__testSseTransport || host.__testSseTransports?.[server] || host.__mockServerStreams?.[server]) return const originalFetch = window.fetch.bind(window) const encoder = new TextEncoder() - const state: { - controller?: ReadableStreamDefaultController - buffer: string[] - connections: number - } = { buffer: [], connections: 0 } - const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\n\n` - const stream = { - push(payloads: unknown[]) { - const frames = payloads.map(frame) + const state: MockStreamState = { buffer: [], connections: 0 } + const frame = (data: string) => `data: ${data}\n\n` + + const stream: MockStream = { + push(payloads) { + const frames = payloads.map((payload) => frame(JSON.stringify(payload))) const controller = state.controller + if (!controller) { state.buffer.push(...frames) + return } + frames.forEach((item) => controller.enqueue(encoder.encode(item))) }, } + host.__mockServerStreams = { ...host.__mockServerStreams, [server]: stream } + const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) + if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request) state.connections += 1 const id = state.connections let ended = false let own: ReadableStreamDefaultController | undefined let keepalive: ReturnType | undefined + const body = new ReadableStream({ start(controller) { own = controller state.controller = controller + if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) controller.enqueue( - encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + encoder.encode( + frame(JSON.stringify({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ), ) state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item))) + // Match the real server's idle stream so long scenarios do not // trigger the client's 45-second stall watchdog and reload history. if (idle) keepalive = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 15_000) @@ -203,6 +248,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (ended) return ended = true clearInterval(keepalive) + if (state.controller === controller) state.controller = undefined controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError")) }, @@ -213,9 +259,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (ended) return ended = true clearInterval(keepalive) + if (state.controller === own) state.controller = undefined }, }) + return Promise.resolve( new Response(body, { status: 200, @@ -223,6 +271,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }), ) } + Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch }) }, { server, retry: config.eventRetry, keepalive: config.keepalive !== false }, @@ -231,30 +280,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { // Delivers events on this server's mock stream; buffered until the app connects. // An origin served by an SSE transport receives the events as one burst on its active connection. const push = (payloads: readonly OpenCodeEvent[]) => - page.evaluate( + page.evaluate( ({ server, payloads }) => { + // SAFETY: only the init script, `installSseTransport`, and benchmarks write these globals, in the declared shapes. const host = window as MockStreamWindow const stream = host.__mockServerStreams?.[server] + if (stream) return stream.push(payloads) const transport = host.__testSseTransports?.[server] + if (!transport) throw new Error(`No mock event stream for ${server}`) transport.command({ type: "send", deliveries: payloads.map((payload) => ({ payload })), burst: true }) }, - { server, payloads: payloads as unknown[] }, + { server, payloads }, ) + // Server-side events the mock publishes itself; delivery failures other than a missing document fail the test. const emit = (events: OpenCodeEvent[]) => - void push(events).catch((error: unknown) => { - if (page.isClosed() || retryableDelivery(error)) return - throw error + void push(events).catch((cause: unknown) => { + if (page.isClosed() || retryableDelivery(cause)) return + throw cause }) if (config.events) { // Batches stay queued until the page accepts them; failures other than a missing document fail the test. - const pump = { busy: false, pending: [] as OpenCodeEvent[] } + const pending: OpenCodeEvent[] = [] + const pump = { busy: false, pending } + const timer = setInterval(() => { if (pump.busy) return pump.pending.push(...(config.events?.() ?? [])) + if (pump.pending.length === 0) return pump.busy = true const batch = pump.pending.slice() @@ -263,31 +319,39 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { () => { pump.pending.splice(0, batch.length) }, - (error: unknown) => { + (cause: unknown) => { if (page.isClosed()) return clearInterval(timer) - if (retryableDelivery(error)) return + + if (retryableDelivery(cause)) return clearInterval(timer) - throw error + throw cause }, ) .finally(() => { pump.busy = false }) }, 50) + page.on("close", () => clearInterval(timer)) } + const transport = createMockServerHandler(config, emit) page.on("close", () => void transport.dispose()) await page.route("**/api/**", async (route) => { const url = new URL(route.request().url()) + if (!answers(page, server, url)) return route.fallback() + // Production serves the UI and API from one origin; leave app assets to Vite. if (!url.pathname.startsWith("/api/")) return route.fallback() + if (route.request().method() === "OPTIONS") { return route.fulfill({ status: 204, headers: corsHeaders }) } + const password = config.password === undefined ? undefined : resolve(config.password) + if ( password !== undefined && (await route.request().headerValue("authorization")) !== `Basic ${btoa(`opencode:${password}`)}` @@ -295,15 +359,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return route.fulfill({ status: 401, headers: corsHeaders, - json: { _tag: "UnauthorizedError", message: "Authentication required" }, + json: Schema.encodeSync(MockUnauthorized)(new MockUnauthorized({ message: "Authentication required" })), }) } + const directory = url.searchParams.get("directory") ?? url.searchParams.get("location[directory]") + if (config.strictDirectory && directory && !ownedDirectories(config).has(directory)) { return route.fulfill({ status: 500, headers: corsHeaders, json: { name: "InvalidDirectory" } }) } const body = route.request().postDataBuffer() + const response = await transport.handler( new Request(url, { method: route.request().method(), @@ -311,7 +378,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { body: body ? Uint8Array.from(body) : undefined, }), ) + const payload = Buffer.from(await response.arrayBuffer()) + // A handler's 404 carries a tagged error; a route the mock does not define must not reach the app server, whose // SPA fallback would answer HTML 200. Answer 501 and fail the test from the route handler. if (response.status === 404 && !payload.toString().includes('"_tag"')) { @@ -323,6 +392,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) throw new Error(`Unmocked API request: ${request} (add it to e2e/utils/mock-server.ts)`) } + return route.fulfill({ status: response.status, headers: { ...Object.fromEntries(response.headers), ...corsHeaders }, @@ -338,10 +408,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const url = new URL(ws.url()) const id = decodeURIComponent(url.pathname.split("/")[3]!) const reason = transport.pty.admit(id, url) + if (reason) { transport.pty.rejected.push({ id, url, reason }) + return ws.close({ code: 1008, reason }) } + const socket: MockPtySocket = { id, url, @@ -349,6 +422,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { closed: false, send: (data) => ws.send(data), } + ws.onMessage((message) => socket.input.push(message.toString())) ws.onClose(() => { socket.closed = true @@ -371,7 +445,9 @@ export async function mockServers(page: Page, servers: Record void = () => {}) { const pty = createPty(config) + const web = HttpRouter.toWebHandler( HttpApiBuilder.layer(MockApi).pipe( Layer.provide( @@ -390,7 +467,7 @@ export function createMockServerHandler(config: MockServerConfig, emit: (events: sessionCreates: 0, pty, emit, - mcp: new Map>(), + mcp: new Map(), attempts: new Map(), }), ), @@ -398,6 +475,7 @@ export function createMockServerHandler(config: MockServerConfig, emit: (events: ), { disableLogger: true }, ) + return { ...web, pty } } @@ -411,13 +489,16 @@ const corsHeaders = { const APP_ORIGIN = new URL( process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`, ).origin + const registeredOrigins = new WeakMap>() function mockedOrigins(page: Page) { const found = registeredOrigins.get(page) + if (found) return found const created = new Set() registeredOrigins.set(page, created) + return created } @@ -425,12 +506,14 @@ function mockedOrigins(page: Page) { // answers unless a server was configured for the app origin explicitly. function answers(page: Page, server: string, url: URL) { if (url.origin === server) return true + return server === SERVER && url.origin === APP_ORIGIN && !mockedOrigins(page).has(APP_ORIGIN) } // The document is not loaded yet or is being replaced; the pump retries on its next tick. -function retryableDelivery(error: unknown) { - const message = error instanceof Error ? error.message : String(error) +function retryableDelivery(cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause) + return [ "No mock event stream", "Execution context was destroyed", @@ -450,12 +533,14 @@ function createPty(config: MockServerConfig) { status: "running", pid: 1, }) + // Core scopes PTYs by Location, which can differ from the process cwd, so ownership is kept apart from `cwd`. const owners = new Map((config.pty?.initial ?? []).map((item) => [item.id, item.directory ?? config.directory])) // Issued, not yet used connect tickets, each bound to one PTY and workspace. const tickets: { id: string; directory: string; ticket: string }[] = [] const issued = { count: 0 } const owns = (item: MockPtyInfo, directory: string) => owners.get(item.id) === directory + const pty: MockPty = { list: (config.pty?.initial ?? []).map((item) => info(item.id, item.title, item.cwd ?? item.directory)), created: [], @@ -466,19 +551,23 @@ function createPty(config: MockServerConfig) { rejected: [], send(data, id) { const socket = pty.sockets.findLast((item) => !item.closed && (id === undefined || item.id === id)) + if (!socket) throw new Error(`No open PTY socket${id ? ` for ${id}` : ""}`) socket.send(data) }, } + return Object.assign(pty, { info, // The first `${prefix}` no initial, created, or removed PTY has used. allocate() { const prefix = config.pty?.prefix ?? "pty_" const used = new Set([...pty.list, ...pty.created].map((item) => item.id).concat(pty.removed)) + const number = Array.from({ length: used.size + 1 }, (_, index) => index + 1).find( (value) => !used.has(`${prefix}${value}`), )! + return { id: `${prefix}${number}`, number } }, add(created: MockPtyInfo, directory: string) { @@ -493,19 +582,24 @@ function createPty(config: MockServerConfig) { issued.count += 1 const ticket = issued.count === 1 ? PTY_TICKET : `${PTY_TICKET}-${issued.count}` tickets.push({ id, directory, ticket }) + return ticket }, // Returns why a connect URL is refused: the PTY must exist, belong to the requested workspace, and carry the exact // unused ticket issued for that PTY and workspace. Admission consumes the ticket. admit(id: string, url: URL) { const found = pty.list.find((item) => item.id === id) + if (!found) return "PTY not found" const directory = url.searchParams.get("location[directory]") || config.directory + if (!owns(found, directory)) return "PTY belongs to another workspace" const ticket = url.searchParams.get("ticket") + const index = tickets.findIndex( (item) => item.id === id && item.directory === directory && item.ticket === ticket, ) + if (index < 0) return "No unused ticket was issued for this PTY" tickets.splice(index, 1) }, @@ -514,23 +608,26 @@ function createPty(config: MockServerConfig) { // Every directory this server's configuration names: its own, project and inventory worktrees, and session locations. function ownedDirectories(config: MockServerConfig) { - const projects = [config.project, ...(config.projects ? resolve(config.projects) : [])].filter(record) + const projects = [config.project, ...(config.projects ? resolve(config.projects) : [])].flatMap((item) => + Option.toArray(decodeProject(item)), + ) + return new Set( [ config.directory, - ...projects.flatMap((item) => [ - item.worktree, - item.canonical, - ...(Array.isArray(item.sandboxes) ? item.sandboxes : []), - ]), - ...(config.worktrees ? resolve(config.worktrees) : []).filter(record).map((item) => item.directory), - ...config.sessions.map((session) => (record(session.location) ? session.location.directory : session.directory)), - ].filter((item): item is string => typeof item === "string"), + ...projects.flatMap((item) => [item.worktree, item.canonical, ...(item.sandboxes ?? [])]), + ...(config.worktrees ? resolve(config.worktrees) : []).filter(Predicate.isObject).map((item) => item.directory), + ...config.sessions.map((session) => + Predicate.isObject(session.location) ? session.location.directory : session.directory, + ), + ].filter(Predicate.isString), ) } function addSandbox(config: MockServerConfig, directory: string) { + // SAFETY: a scenario that creates worktrees seeds its project as an object whose `sandboxes` lists directories. const project = config.project as { sandboxes?: string[] } + if (project.sandboxes?.includes(directory)) return project.sandboxes = [...(project.sandboxes ?? []), directory] } @@ -541,7 +638,8 @@ function requestDirectory(config: MockServerConfig, request: { url: string }) { } function resolve(value: Resolvable) { - return typeof value === "function" ? (value as () => T)() : value + // SAFETY: no resolvable scenario value is itself a function, so a function is the value's resolver. + return Predicate.isFunction(value) ? (value as () => T)() : value } function mockHandlers( @@ -553,16 +651,18 @@ function mockHandlers( pty: ReturnType emit: (events: OpenCodeEvent[]) => void // MCP status overrides by `\n`, and OAuth attempt creation times by attempt ID. - mcp: Map> + mcp: Map attempts: Map }, ) { const noContent = Effect.succeed(HttpApiSchema.NoContent.make()) const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay)) const configEntries = config.configEntries ?? [] + const ptyEnabled = Effect.suspend(() => config.pty ? Effect.void : Effect.fail(new MockNotFound({ message: "PTY is not enabled for this mock server" })), ) + // PTYs are scoped to the workspace (`location[directory]`) they were created in, like the real server. const findPty = (id: string, request: { url: string }) => ptyEnabled.pipe( @@ -570,46 +670,59 @@ function mockHandlers( Effect.suspend(() => { const directory = requestDirectory(config, request) const found = state.pty.find(id, directory) + return found ? Effect.succeed(found) : Effect.fail(new MockNotFound({ message: "PTY not found" })) }), ), ) + const mcpServers = (directory: string) => - (typeof config.mcp === "function" ? config.mcp(directory) : (config.mcp ?? [])).map((server) => { - const status = record(server) ? state.mcp.get(`${directory}\n${String(server.name)}`) : undefined - return status && record(server) ? { ...server, status } : server + (Predicate.isFunction(config.mcp) ? config.mcp(directory) : (config.mcp ?? [])).map((server) => { + if (!Predicate.isObject(server)) return server + const status = state.mcp.get(`${directory}\n${String(server.name)}`) + + return status ? { ...server, status } : server }) + const mcpAction = (server: string, action: "connect" | "disconnect", request: { url: string }) => Effect.suspend(() => { const directory = requestDirectory(config, request) - if (!mcpServers(directory).some((item) => record(item) && item.name === server)) + + if (!mcpServers(directory).some((item) => Predicate.isObject(item) && item.name === server)) return Effect.fail(new MockNotFound({ message: `MCP server ${server} not found` })) + const status = config.onMcpAction?.({ server, action, directory }) ?? { status: action === "connect" ? "connected" : "disabled", } + state.mcp.set(`${directory}\n${server}`, status) + return noContent }) + const unsupported = (operation: string, handler: string) => Effect.fail( new MockUnsupported({ message: `The mock server does not ${operation}; configure ${handler} for this scenario` }), ) + return HttpApiBuilder.group(MockApi, "mock", (handlers) => handlers .handleRaw("event", () => { const events = config.events?.() const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n` + const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])] .map((event) => `data: ${JSON.stringify(event)}\n\n`) .join("") + return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" })) }) .handleRaw("fsRead", (ctx) => Effect.gen(function* () { const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)) const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path))) - const content = - value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "") + const content = Predicate.isString(value) ? value : (value?.content ?? "") + return HttpServerResponse.uint8Array(new TextEncoder().encode(content)) }), ) @@ -617,40 +730,50 @@ function mockHandlers( .handleRaw("worktreeCreate", (ctx) => Effect.gen(function* () { const create = config.onWorktreeCreate + if (!create) return yield* unsupported("create worktrees", "onWorktreeCreate") - const input = yield* Effect.orDie(ctx.request.json) - const payload = record(input) ? input : {} + + const input = yield* Effect.orDie( + ctx.request.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Json))), + ) + + const payload = Option.getOrUndefined(decodeWorktreeRequest(input)) + const answer = (yield* Effect.promise(async () => create(input))) || { status: 200, - body: { - directory: `${typeof payload.directory === "string" ? payload.directory : config.directory}/${ - typeof payload.name === "string" ? payload.name : "copy" - }`, - }, + body: { directory: `${payload?.directory ?? config.directory}/${payload?.name ?? "copy"}` }, } - if (answer.status === 200 && record(answer.body) && typeof answer.body.directory === "string") - addSandbox(config, answer.body.directory) + + const created = answer.status === 200 ? decodeWorktreeAnswer(answer.body) : Option.none() + + if (Option.isSome(created)) addSandbox(config, created.value.directory) + return HttpServerResponse.jsonUnsafe(answer.body, { status: answer.status }) }), ) .handleRaw("sessionCreate", (ctx) => Effect.gen(function* () { const input = yield* Effect.orDie(ctx.request.json) - const payload = record(input) ? input : {} + const payload = Option.getOrElse(decodeJsonObject(input), () => ({})) + const fields = Option.getOrUndefined(decodeSessionCreate(payload)) state.sessionCreates += 1 const answer = config.onSessionCreate?.(payload, state.sessionCreates) + if (answer) return HttpServerResponse.jsonUnsafe(answer.body, { status: answer.status }) + const created = currentSession( { ...payload, - id: typeof payload.id === "string" ? payload.id : "ses_mock_created", - projectID: (config.project as { id?: string }).id, - title: config.createdSessionTitle ?? (typeof payload.title === "string" ? payload.title : "New session"), - parentID: typeof payload.parentID === "string" ? payload.parentID : undefined, + id: fields?.id ?? "ses_mock_created", + projectID: projectSeed(config)?.id, + title: config.createdSessionTitle ?? fields?.title ?? "New session", + parentID: fields?.parentID, }, config.directory, ) + config.sessions.push(created) + return HttpServerResponse.jsonUnsafe({ data: created }) }), ) @@ -663,18 +786,7 @@ function mockHandlers( paths: { tmp: "/tmp/opencode" }, }), config: () => Effect.succeed(configEntries), - reference: () => - Effect.succeed({ - location: { - directory: config.directory, - project: { - id: (config.project as { id?: string }).id, - directory: config.directory, - canonical: config.directory, - }, - }, - data: [], - }), + reference: () => Effect.succeed({ location: location(config), data: [] }), agent: (ctx) => Effect.succeed({ location: location(config, requestDirectory(config, ctx.request)), @@ -689,16 +801,17 @@ function mockHandlers( }, ], }), - provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }), - model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }), + provider: () => + Effect.succeed({ location: location(config), data: currentProviders(resolve(config.provider)) }), + model: () => Effect.succeed({ location: location(config), data: currentModels(resolve(config.provider)) }), modelDefault: () => - Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }), + Effect.succeed({ location: location(config), data: currentDefaultModel(resolve(config.provider)) }), integrationList: () => Effect.succeed({ location: location(config), data: config.integrations ?? [] }), integrationGet: (ctx) => Effect.succeed({ location: location(config), data: config.integrations - ?.filter(record) + ?.filter(Predicate.isObject) .find((integration) => integration.id === ctx.params.integrationID) ?? { id: ctx.params.integrationID, name: ctx.params.integrationID, @@ -712,13 +825,16 @@ function mockHandlers( ), integrationOAuthConnect: (ctx) => { const start = config.onIntegrationOAuth + if (!start) return unsupported("start OAuth connections", "onIntegrationOAuth") + return Effect.sync(() => { const directory = requestDirectory(config, ctx.request) const created = Date.now() const attemptID = `con_mock_${state.attempts.size + 1}` state.attempts.set(attemptID, created) const started = start({ integrationID: ctx.params.integrationID, directory, body: ctx.payload }) + return { location: location(config, directory), data: { @@ -734,7 +850,9 @@ function mockHandlers( integrationOAuthStatus: (ctx) => Effect.suspend(() => { const created = state.attempts.get(ctx.params.attemptID) + if (created === undefined) return Effect.fail(new MockNotFound({ message: "OAuth attempt not found" })) + return Effect.succeed({ location: location(config, requestDirectory(config, ctx.request)), data: { status: "pending", time: { created, expires: created + 600_000 } }, @@ -751,6 +869,7 @@ function mockHandlers( mcp: (ctx) => Effect.sync(() => { const directory = requestDirectory(config, ctx.request) + return { location: location(config, directory), data: mcpServers(directory) } }), mcpConnect: (ctx) => mcpAction(ctx.params.server, "connect", ctx.request), @@ -763,27 +882,27 @@ function mockHandlers( projectList: () => Effect.sync(() => { if (config.projects) return resolve(config.projects) - const project = config.project as typeof config.project & { canonical?: string; worktree?: string } - return [{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }] + const seed = projectSeed(config) + + return [{ ...projectFields(config), canonical: seed?.canonical ?? seed?.worktree ?? config.directory }] }), - projectUpdate: (ctx) => { - const project = config.project as { canonical?: string } - return Effect.succeed({ - ...project, + projectUpdate: (ctx) => + Effect.succeed({ + ...projectFields(config), ...ctx.payload, id: ctx.params.projectID, - canonical: project.canonical ?? config.directory, - }) - }, + canonical: projectSeed(config)?.canonical ?? config.directory, + }), configShells: () => Effect.succeed(config.shells ?? []), configUpdate: () => noContent, websearchProviders: () => Effect.succeed({ location: location(config), data: [] }), worktreeList: () => Effect.sync(() => { if (config.worktrees) return resolve(config.worktrees) + return [ { directory: config.directory }, - ...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({ + ...(projectSeed(config)?.sandboxes ?? []).map((directory) => ({ directory, strategy: "git", })), @@ -791,7 +910,9 @@ function mockHandlers( }), worktreeRemove: (ctx) => { const remove = config.onWorktreeRemove + if (!remove) return unsupported("remove worktrees", "onWorktreeRemove") + return Effect.promise(async () => remove(ctx.payload)).pipe(Effect.andThen(noContent)) }, // Discovery against a static inventory changes nothing, and the app refreshes whenever worktree settings open. @@ -803,17 +924,10 @@ function mockHandlers( ? Effect.fail(new MockInternal({ message: "Permission list failed" })) : Effect.succeed({ location: location(config), - data: (typeof config.permissions === "function" - ? config.permissions() - : (config.permissions ?? []) - ).map(currentPermission), + data: currentPermissions(resolve(config.permissions ?? [])), }), ), - formRequests: () => - Effect.succeed({ - location: location(config), - data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), - }), + formRequests: () => Effect.succeed({ location: location(config), data: resolve(config.forms ?? []) }), vcs: () => Effect.succeed({ location: location(config), @@ -821,10 +935,12 @@ function mockHandlers( }), vcsInit: (ctx) => { if (!config.onVcsInit) return unsupported("initialize VCS", "onVcsInit") + return Effect.sync(() => { const url = new URL(ctx.request.url, "http://localhost") const provider = url.searchParams.get("provider") ?? undefined config.onVcsInit?.({ directory: requestDirectory(config, ctx.request), provider }) + // SAFETY: a scenario that initializes VCS seeds its project as an object with an ID. const project = config.project as { id: string; vcs?: string } project.vcs = provider ?? "git" state.emit([ @@ -842,8 +958,9 @@ function mockHandlers( vcsDiff: (ctx) => Effect.sync(() => ({ location: location(config), - data: - typeof config.vcsDiff === "function" ? config.vcsDiff({ mode: ctx.query.mode }) : (config.vcsDiff ?? []), + data: Predicate.isFunction(config.vcsDiff) + ? config.vcsDiff({ mode: ctx.query.mode }) + : (config.vcsDiff ?? []), })), fsList: (ctx) => Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe( @@ -859,7 +976,7 @@ function mockHandlers( location: location(config), data: Array.isArray(entries) ? entries.map((entry) => - typeof entry === "string" + Predicate.isString(entry) ? { name: entry.split(/[\\/]/).at(-1) ?? entry, path: entry, @@ -874,11 +991,14 @@ function mockHandlers( ), fsWrite: (ctx) => { const write = config.onFileWrite + if (!write) return unsupported("write files", "onFileWrite") + return Effect.sync(() => { const directory = requestDirectory(config, ctx.request) const path = new URL(ctx.request.url, "http://localhost").searchParams.get("path") ?? "" write({ path, directory, body: new TextDecoder().decode(ctx.payload) }) + return { location: location(config, directory), data: { path } } }) }, @@ -891,6 +1011,7 @@ function mockHandlers( Effect.suspend(() => { const directory = requestDirectory(config, ctx.request) const output = config.shellOutput?.({ id: ctx.params.id, directory }) + if (output === undefined) return Effect.fail( new MockShellNotFound({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }), @@ -900,6 +1021,7 @@ function mockHandlers( const cursor = Math.min(Number(query.get("cursor") ?? 0), bytes.length) // The server answers at most one page (`Shell.output` defaults `limit` to 65,536 bytes). const end = Math.min(bytes.length, cursor + Number(query.get("limit") ?? 65_536)) + return Effect.succeed({ location: location(config, directory), data: { @@ -914,6 +1036,7 @@ function mockHandlers( ptyEnabled.pipe( Effect.map(() => { const directory = requestDirectory(config, ctx.request) + return { location: location(config, directory), data: state.pty.owned(directory) } }), ), @@ -922,12 +1045,15 @@ function mockHandlers( Effect.map(() => { const next = state.pty.allocate() const directory = requestDirectory(config, ctx.request) + const created = state.pty.info( next.id, ctx.payload.title ?? `Terminal ${next.number}`, ctx.payload.cwd ?? directory, ) + state.pty.add(created, directory) + return { location: location(config, directory), data: created } }), ), @@ -939,7 +1065,9 @@ function mockHandlers( findPty(ctx.params.ptyID, ctx.request).pipe( Effect.map((found) => { state.pty.updates.push({ id: found.id, body: ctx.payload }) + if (ctx.payload.title) found.title = ctx.payload.title + return { location: location(config, requestDirectory(config, ctx.request)), data: found } }), ), @@ -948,6 +1076,7 @@ function mockHandlers( Effect.map((found) => { state.pty.removed.push(found.id) state.pty.list.splice(state.pty.list.indexOf(found), 1) + return HttpApiSchema.NoContent.make() }), ), @@ -961,22 +1090,23 @@ function mockHandlers( headers: Object.fromEntries(Object.entries(ctx.request.headers)), ticket, }) + return { location: location(config, directory), data: { ticket, expires_in: 60 } } }), ), sessionList: (ctx) => { const sessions = config.sessions - .filter((session) => { - const location = session.location as { directory?: string } | undefined - return ( + .filter( + (session) => !ctx.query.directory || - location?.directory === ctx.query.directory || - session.directory === ctx.query.directory - ) - }) + (Predicate.isObject(session.location) && session.location.directory === ctx.query.directory) || + session.directory === ctx.query.directory, + ) .filter((session) => { if (ctx.query.parentID === undefined) return true + if (ctx.query.parentID === "null") return session.parentID === undefined + return session.parentID === ctx.query.parentID }) .filter((session) => @@ -986,66 +1116,61 @@ function mockHandlers( .toLowerCase() .includes(ctx.query.search.toLowerCase()), ) + const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed() const offset = Number(ctx.query.cursor ?? 0) const limit = ctx.query.limit ?? 50 const data = ordered.slice(offset, offset + limit) + return Effect.succeed({ data: data.map((session) => currentSession(session, config.directory)), cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined }, }) }, - sessionActive: () => { - const statuses = ( - typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}) - ) as Record - return Effect.succeed({ + sessionActive: () => + Effect.succeed({ data: Object.fromEntries( - Object.entries(statuses).flatMap(([id, status]) => + Object.entries(resolve(config.sessionStatus ?? {})).flatMap(([id, status]) => status.type === "idle" ? [] : [[id, { type: "running" }]], ), ), - }) - }, + }), sessionGet: (ctx) => Effect.suspend(() => { config.onSession?.(ctx.params.sessionID) const session = config.sessions.find((item) => item.id === ctx.params.sessionID) + return session ? Effect.succeed({ data: currentSession(session, config.directory) }) : Effect.fail(new MockNotFound({ message: "Session not found" })) }), sessionRemove: () => noContent, sessionShell: () => noContent, - sessionForm: (ctx) => { - const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? []) - return Effect.succeed({ - data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID), - }) - }, + sessionForm: (ctx) => + Effect.succeed({ + data: resolve(config.forms ?? []).filter( + (form) => Predicate.isObject(form) && form.sessionID === ctx.params.sessionID, + ), + }), sessionFormReply: () => noContent, sessionFormCancel: () => noContent, sessionBackground: () => noContent, - sessionInbox: () => - Effect.sync(() => ({ data: typeof config.inbox === "function" ? config.inbox() : (config.inbox ?? []) })), + sessionInbox: () => Effect.sync(() => ({ data: resolve(config.inbox ?? []) })), sessionPrompt: (ctx) => Effect.sync(() => { - const body = record(ctx.payload) ? ctx.payload : {} + const body = Option.getOrElse(decodeJsonObject(ctx.payload), () => ({})) config.onPrompt?.({ sessionID: ctx.params.sessionID, body }) + const prompt = Option.getOrUndefined(decodePromptRequest(body)) + return { data: { - id: typeof body.id === "string" ? body.id : `inb_mock_${Date.now()}`, + id: prompt?.id ?? `inb_mock_${Date.now()}`, sessionID: ctx.params.sessionID, time: { created: Date.now() }, type: "user", - payload: { - text: typeof body.text === "string" ? body.text : "", - ...(body.files === undefined ? {} : { files: body.files }), - ...(body.agents === undefined ? {} : { agents: body.agents }), - ...(body.skills === undefined ? {} : { skills: body.skills }), - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), - }, - delivery: body.delivery === "queue" ? "queue" : "steer", + // Keys the request omits stay omitted. + payload: { text: "", ...Option.getOrUndefined(decodePromptPayload(body)) }, + delivery: prompt?.delivery ?? "steer", }, } }), @@ -1072,24 +1197,26 @@ function mockHandlers( // Only the session's own list; location requests (`permissions`) come from `/api/permission/request`. sessionPermission: (ctx) => Effect.sync(() => ({ - data: (config.sessionPermissions?.[ctx.params.sessionID] ?? []) - .map(currentPermission) - .filter((permission) => permission.sessionID === ctx.params.sessionID), + data: currentPermissions(config.sessionPermissions?.[ctx.params.sessionID] ?? []).filter( + (permission) => permission.sessionID === ctx.params.sessionID, + ), })), // Like the server, a reply publishes `permission.replied`, and later reads no longer list the request. sessionPermissionReply: (ctx) => { const reply = config.onPermissionReply + if (!reply) return unsupported("record permission replies", "onPermissionReply") + return Effect.sync(() => { const sessionID = ctx.params.sessionID const permissionID = ctx.params.permissionID reply({ sessionID, permissionID, body: ctx.payload }) - const pending = [ - config.sessionPermissions?.[sessionID] ?? [], - typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []), - ] + + const pending = [config.sessionPermissions?.[sessionID] ?? [], resolve(config.permissions ?? [])] + pending.forEach((list) => { - const index = list.findIndex((item) => record(item) && item.id === permissionID) + const index = list.findIndex((item) => Predicate.isObject(item) && item.id === permissionID) + if (index >= 0) list.splice(index, 1) }) state.emit([ @@ -1101,33 +1228,38 @@ function mockHandlers( data: { sessionID, requestID: permissionID, - reply: - record(ctx.payload) && typeof ctx.payload.decision === "string" ? ctx.payload.decision : "once", + reply: Option.getOrUndefined(decodePermissionReply(ctx.payload))?.decision ?? "once", }, - } as OpenCodeEvent, + }, ]) }).pipe(Effect.andThen(noContent)) }, sessionRename: (ctx) => Effect.sync(() => { - const title = record(ctx.payload) ? ctx.payload.title : undefined + const title = Option.getOrUndefined(decodeSessionRename(ctx.payload))?.title const session = config.sessions.find((item) => item.id === ctx.params.sessionID) - if (session && typeof title === "string") session.title = title + + if (session && title !== undefined) session.title = title }).pipe(Effect.andThen(noContent)), sessionCommand: (ctx) => { const recordCommand = config.onCommand + if (!recordCommand) return unsupported("run session commands", "onCommand") + return Effect.sync(() => recordCommand({ sessionID: ctx.params.sessionID, body: ctx.payload })).pipe( Effect.andThen(noContent), ) }, - sessionInterrupt: () => noContent, + // Like the server for an idle session: nothing was running to interrupt. + sessionInterrupt: () => Effect.succeed({ interrupted: false }), + // The mock runs no agent loop, so every session is already idle. + sessionWait: () => noContent, sessionRevertStage: (ctx) => { - const payload = record(ctx.payload) ? ctx.payload : {} - const messageID = payload.messageID - if (typeof messageID !== "string") { - return Effect.fail(new MockBadRequest({ message: "Invalid revert request" })) - } + const request = decodeRevertStage(ctx.payload) + + if (Option.isNone(request)) return Effect.fail(new MockBadRequest({ message: "Invalid revert request" })) + const messageID = request.value.messageID + return Effect.sync(() => config.onRevertStage?.({ sessionID: ctx.params.sessionID, messageID })).pipe( Effect.as({ data: { messageID } }), ) @@ -1138,28 +1270,37 @@ function mockHandlers( Effect.gen(function* () { config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }) yield* delay + const message = config.message?.(ctx.params.sessionID, ctx.params.messageID) ?? config .pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER) .items.find((item) => item.id === ctx.params.messageID) + if (!message) return yield* new MockNotFound({ message: "Message not found" }) + return { data: message } }), messageList: (ctx) => { const token = ctx.query.cursor const before = token ? state.cursors.get(token) : undefined + if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" })) + return Effect.gen(function* () { config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" }) + if (config.beforeMessagesResponse) { yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before })) } + yield* delay const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before) config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" }) const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined + if (cursor) state.cursors.set(cursor, pageData.cursor!) + return { data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(), cursor: { next: cursor }, @@ -1174,104 +1315,96 @@ function mockHandlers( function location(config: MockServerConfig, directory = config.directory) { return { directory, - project: { id: (config.project as { id?: string }).id, directory: config.directory, canonical: config.directory }, + project: { id: projectSeed(config)?.id, directory: config.directory, canonical: config.directory }, } } -function providerConfig(config: MockServerConfig) { - return typeof config.provider === "function" ? config.provider() : config.provider +// The project fields the mock reads. +function projectSeed(config: MockServerConfig) { + return Option.getOrUndefined(decodeProject(config.project)) } -function currentProviders(value: unknown) { - if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : [] - const connected = new Set( - Array.isArray(value.connected) ? value.connected.filter((id) => typeof id === "string") : [], - ) - return value.all.filter(record).flatMap((provider) => - typeof provider.id === "string" && typeof provider.name === "string" - ? [ - { - id: provider.id, - name: provider.name, - package: provider.id, - activation: connected.has(provider.id) ? "enabled" : "auto", - }, - ] - : [], +// Every field of the configured project, for answers that echo it. +function projectFields(config: MockServerConfig) { + return Predicate.isObject(config.project) ? config.project : undefined +} + +function currentProviders(catalog: MockProviderCatalog) { + const connected = new Set(catalog.connected ?? []) + + return (catalog.all ?? []) + .flatMap((item) => Option.toArray(decodeProvider(item))) + .map((provider) => ({ + id: provider.id, + name: provider.name, + package: provider.id, + activation: connected.has(provider.id) ? "enabled" : "auto", + })) +} + +function currentModels(catalog: MockProviderCatalog) { + return (catalog.all ?? []) + .flatMap((item) => Option.toArray(decodeModelProvider(item))) + .flatMap((provider) => + Object.values(provider.models) + .flatMap((item) => Option.toArray(decodeModel(item))) + .map((model) => ({ + id: model.id, + modelID: model.api?.id ?? model.id, + providerID: provider.id, + name: model.name, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: Object.entries(model.variants ?? {}).map(([id, settings]) => + Predicate.isObject(settings) ? { id, settings } : { id }, + ), + time: { released: Date.now() }, + cost: [{ input: model.cost?.input ?? 0, output: model.cost?.output ?? 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: model.limit?.context ?? 200_000, output: model.limit?.output ?? 32_000 }, + })), + ) +} + +function currentDefaultModel(catalog: MockProviderCatalog) { + const selected = catalog.default + + if (!selected) return null + + return ( + currentModels(catalog).find((model) => model.providerID === selected.providerID && model.id === selected.modelID) ?? + null ) } -function currentModels(value: unknown) { - if (!record(value) || !Array.isArray(value.all)) return [] - return value.all.filter(record).flatMap((provider) => { - if (typeof provider.id !== "string" || !record(provider.models)) return [] - return Object.values(provider.models) - .filter(record) - .flatMap((model) => { - if (typeof model.id !== "string" || typeof model.name !== "string") return [] - const limit = record(model.limit) ? model.limit : {} - const cost = record(model.cost) ? model.cost : {} - return [ - { - id: model.id, - modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id, - providerID: provider.id, - name: model.name, - capabilities: { tools: true, input: ["text"], output: ["text"] }, - variants: record(model.variants) - ? Object.entries(model.variants).map(([id, settings]) => ({ - id, - ...(jsonRecord(settings) ? { settings: jsonRecord(settings) } : {}), - })) - : [], - time: { released: Date.now() }, - cost: [ - { - input: typeof cost.input === "number" ? cost.input : 0, - output: typeof cost.output === "number" ? cost.output : 0, - cache: { read: 0, write: 0 }, - }, - ], - status: "active", - enabled: true, - limit: { - context: typeof limit.context === "number" ? limit.context : 200_000, - output: typeof limit.output === "number" ? limit.output : 32_000, - }, - }, - ] - }) +// Legacy requests (`permission`, `patterns`, `always`, `tool`) become current ones; current ones (`action`) pass as is. +function currentPermissions(values: readonly unknown[]) { + return values.flatMap((permission) => { + if (!Predicate.isObject(permission)) return [] + + if (permission.action) return [permission] + const tool = Predicate.isObject(permission.tool) ? permission.tool : undefined + + return [ + { + id: permission.id, + sessionID: permission.sessionID, + action: permission.permission, + resources: permission.patterns ?? [], + save: permission.always, + metadata: permission.metadata, + source: + tool?.messageID && (tool.id || tool.callID) + ? { type: "tool", messageID: tool.messageID, id: tool.id ?? tool.callID } + : undefined, + }, + ] }) } -function currentDefaultModel(value: unknown) { - if (!record(value) || !record(value.default)) return null - const selected = value.default - const models = currentModels(value) - return models.find((model) => model.providerID === selected.providerID && model.id === selected.modelID) ?? null -} +export function currentSession(session: MockSession, fallbackDirectory?: string) { + const seed = decodeSession(session) -function currentPermission(value: unknown) { - const permission = value as Record - if (permission.action) return permission - const tool = permission.tool as { messageID?: string; callID?: string; id?: string } | undefined - return { - id: permission.id, - sessionID: permission.sessionID, - action: permission.permission, - resources: permission.patterns ?? [], - save: permission.always, - metadata: permission.metadata, - source: - tool?.messageID && (tool.id || tool.callID) - ? { type: "tool", messageID: tool.messageID, id: tool.id ?? tool.callID } - : undefined, - } -} - -export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { - const time = session.time && typeof session.time === "object" ? session.time : {} - const location = session.location && typeof session.location === "object" ? session.location : {} return { id: session.id, parentID: session.parentID, @@ -1280,47 +1413,93 @@ export function currentSession(session: { id: string } & Record model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, cost: session.cost ?? 0, tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - ...(typeof session.outcome === "string" ? { outcome: session.outcome } : {}), - time: { - created: "created" in time && typeof time.created === "number" ? time.created : 0, - updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, - ...("idle" in time && typeof time.idle === "number" ? { idle: time.idle } : {}), - ...("viewed" in time && typeof time.viewed === "number" ? { viewed: time.viewed } : {}), - ...(session.time && typeof session.time === "object" && "archived" in session.time - ? { archived: session.time.archived } - : {}), - }, + ...decodeSessionOutcome(session), + time: { created: seed.time?.created ?? 0, updated: seed.time?.updated ?? 0, ...seed.time }, title: session.title ?? session.id, - location: { - directory: - "directory" in location && typeof location.directory === "string" - ? location.directory - : typeof session.directory === "string" - ? session.directory - : fallbackDirectory, - }, + location: { directory: seed.location?.directory ?? seed.directory ?? fallbackDirectory }, subpath: session.subpath ?? session.path, revert: session.revert, } } -function jsonRecord(value: unknown): Record | undefined { - if (!record(value)) return - return Object.fromEntries( - Object.entries(value).flatMap(([key, item]) => { - const next = jsonValue(item) - return next === undefined ? [] : [[key, next]] - }), - ) +// An optional field that decodes as absent when its value is invalid, so one bad field does not discard the rest. +function lenient(schema: S) { + return Schema.optionalKey(schema.pipe(Schema.catchDecoding(() => Effect.succeedNone))) } -function jsonValue(value: unknown): JsonValue | undefined { - if (value === null || typeof value === "string" || typeof value === "boolean") return value - if (typeof value === "number") return Number.isFinite(value) ? value : null - if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null) - return jsonRecord(value) -} +const decodeJsonObject = Schema.decodeUnknownOption(Schema.JsonObject) -function record(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value) -} +const decodeProject = Schema.decodeUnknownOption( + Schema.Struct({ + id: lenient(Schema.String), + worktree: lenient(Schema.String), + canonical: lenient(Schema.String), + sandboxes: lenient(Schema.Array(Schema.String)), + }), +) + +const decodeWorktreeRequest = Schema.decodeUnknownOption( + Schema.Struct({ directory: lenient(Schema.String), name: lenient(Schema.String) }), +) + +const decodeWorktreeAnswer = Schema.decodeUnknownOption(Schema.Struct({ directory: Schema.String })) + +const decodeSessionCreate = Schema.decodeUnknownOption( + Schema.Struct({ id: lenient(Schema.String), title: lenient(Schema.String), parentID: lenient(Schema.String) }), +) + +const decodeSessionRename = Schema.decodeUnknownOption(Schema.Struct({ title: Schema.String })) + +const decodeRevertStage = Schema.decodeUnknownOption(Schema.Struct({ messageID: Schema.String })) + +const decodePermissionReply = Schema.decodeUnknownOption(Schema.Struct({ decision: Permission.Reply })) + +const decodePromptRequest = Schema.decodeUnknownOption( + Schema.Struct({ id: lenient(Schema.String), delivery: lenient(Schema.Literal("queue")) }), +) + +const decodePromptPayload = Schema.decodeUnknownOption( + Schema.Struct({ + text: lenient(Schema.String), + files: Schema.optionalKey(Schema.Json), + agents: Schema.optionalKey(Schema.Json), + skills: Schema.optionalKey(Schema.Json), + metadata: Schema.optionalKey(Schema.Json), + }), +) + +const decodeProvider = Schema.decodeUnknownOption(Schema.Struct({ id: Schema.String, name: Schema.String })) + +const decodeModelProvider = Schema.decodeUnknownOption( + Schema.Struct({ id: Schema.String, models: Schema.Record(Schema.String, Schema.Unknown) }), +) + +const decodeModel = Schema.decodeUnknownOption( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + api: lenient(Schema.Struct({ id: Schema.String })), + limit: lenient(Schema.Struct({ context: lenient(Schema.Number), output: lenient(Schema.Number) })), + cost: lenient(Schema.Struct({ input: lenient(Schema.Number), output: lenient(Schema.Number) })), + variants: lenient(Schema.Record(Schema.String, Schema.Unknown)), + }), +) + +// Every field is lenient, so any session object decodes. +const decodeSession = Schema.decodeUnknownSync( + Schema.Struct({ + time: lenient( + Schema.Struct({ + created: lenient(Schema.Number), + updated: lenient(Schema.Number), + idle: lenient(Schema.Number), + viewed: lenient(Schema.Number), + archived: Schema.optionalKey(Schema.Unknown), + }), + ), + location: lenient(Schema.Struct({ directory: lenient(Schema.String) })), + directory: lenient(Schema.String), + }), +) + +const decodeSessionOutcome = Schema.decodeUnknownSync(Schema.Struct({ outcome: lenient(Schema.String) }))