opencode/packages/app/e2e/utils/mock-server.ts

1578 lines
65 KiB
TypeScript

import type { Page } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { Permission } from "@opencode/schema/permission"
import { Model } from "@opencode/schema/model"
import { Worktree } from "@opencode/schema/worktree"
import { Duration, Effect, Layer, Option, Predicate, Schema } from "effect"
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/http-api"
import { SERVER } from "./app"
import {
MockApi,
MockBadRequest,
MockInternal,
MockNotFound,
MockPtyFeatureUnsupported,
MockPtyNotFound,
MockServiceUnavailable,
MockShellNotFound,
MockUnauthorized,
MockUnsupported,
} from "./mock-api"
import { createPersistentPty, type MockPersistentPtyConfig, type MockServerEvent } from "./persistent-pty"
import { installSseTransport } from "./sse-transport"
type Resolvable<T> = 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<string, unknown>
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
// The machine name `/api/info` reports; omitted like a server that predates it.
hostname?: string
provider: Resolvable<MockProviderCatalog>
integrations?: Resolvable<unknown[]>
// Runs on POST /api/credential/:id/activate; the fixture swaps its integrations and catalog to the new account.
onCredentialActivate?: (credentialID: string) => void
// Runs on PATCH /api/credential/:id with the request body; the fixture applies the new label.
onCredentialUpdate?: (credentialID: string, body: { label?: string }) => void
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
// Terminal shells the settings offer (`/api/config/shell`).
shells?: unknown[]
// Background shell commands (`GET /api/shell`).
shellCommands?: Resolvable<unknown[]>
// All output a shell command has captured so far in `directory`, or undefined for an unknown command (404
// ShellNotFoundError). The mock pages it like the server: from the request's byte `cursor`, at most `limit` bytes
// (default 65,536).
shellOutput?: (input: { id: string; directory: string }) => string | undefined
// Records `POST /api/experimental/fs/write` (attachment uploads), which answers the requested path.
// Without it, writes answer 501 MockUnsupported.
onFileWrite?: (input: { path: string; directory: string; body: string }) => void
configEntries?: unknown[]
directory: string
project: unknown
// Replaces the `/api/project` inventory, which defaults to `[project]`. A location inside a listed project
// resolves to that project; any other location resolves to `project`.
projects?: Resolvable<unknown[]>
// Runs on GET /api/location for a named directory before it answers, as the server registers the project of a
// directory it resolves.
onLocationGet?: (directory: string) => void
sessions: MockSession[]
pageMessages: (
sessionId: string,
limit: number,
before?: string,
) => {
items: SessionMessageInfo[]
cursor?: string
}
vcs?: { current: string; default: string } | ((directory: string) => { current: string; default: string })
branches?: (input: { directory: string; search?: string; limit?: number }) => string[] | Promise<string[]>
// Initializes the mock project's VCS; without a handler this mutation answers 501.
onVcsInit?: (input: { directory: string; provider?: string }) => void
vcsDiff?: unknown[] | ((input: { mode?: string }) => unknown[])
// Benchmark latency only. Tests hold message pages with `beforeMessagesResponse`.
messageDelay?: number
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined
onMessage?: (input: { sessionID: string; messageID: string }) => void
onRevertStage?: (input: { sessionID: string; messageID: string }) => void
onSession?: (sessionID: string) => void
events?: () => OpenCodeEvent[]
eventRetry?: number
// Idle event streams send a comment every 15 s like the real server. Set false only to test the client's stall watchdog.
keepalive?: boolean
permissions?: unknown[] | (() => unknown[])
// Requests only listed by `/api/session/:id/permission`, keyed by session ID.
sessionPermissions?: Record<string, unknown[]>
// Returning true fails the next `/api/permission/request` listing with a 500.
permissionListFailures?: () => boolean
// Without it, permission replies answer 501 MockUnsupported.
onPermissionReply?: (input: { sessionID: string; permissionID: string; body: unknown }) => void
forms?: unknown[] | (() => unknown[])
// MCP servers. A list serves every workspace; a function receives the requested directory.
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 | 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 }
// Answers status polls for an OAuth attempt; without it every attempt stays pending.
onIntegrationOAuthStatus?: (attemptID: string) => "pending" | "complete"
plugins?: Resolvable<unknown[]>
skills?: Resolvable<unknown[]>
// Replaces the `/api/worktree` inventory, which defaults to the directory plus project sandboxes.
worktrees?: Resolvable<unknown[]>
// Without them, creating or removing a worktree answers 501 MockUnsupported. `onWorktreeCreate` may hold the request
// and return the answer; by default it creates `<directory>/<name>`. A created directory joins the project's sandboxes.
onWorktreeCreate?: (input: Schema.Json) => void | MockAnswer | Promise<void | MockAnswer>
onWorktreeRemove?: (input: Worktree.RemoveInput) => void | Promise<void>
// POST /api/session keeps the client-reserved `id` and `location`. Return an answer to fail the attempt (1-based).
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<unknown[]>
// 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<unknown[]>
fileContent?: (path: string) => MockFileContent | Promise<MockFileContent>
// Paths become directory entries.
findFiles?: (input: { query: string; dirs?: string; limit?: number; directory: string }) => unknown[]
sessionStatus?: Resolvable<Record<string, { type: string }>>
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Schema.JsonObject }) => void
onCompact?: (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
onBackground?: (input: { sessionID: string }) => void
// Serves the session-scoped persistent PTY routes and their connect WebSockets (`e2e/utils/persistent-pty.ts`).
// Without it, the server has a working daemon and no terminals; `capability: false` makes the routes answer 503.
persistentPty?: MockPersistentPtyConfig
// Answers 500 InvalidDirectory when a request names a directory this server does not own.
strictDirectory?: boolean
// Answers 401 UnauthorizedError unless a request carries this password; a function may change it mid-test.
password?: Resolvable<string>
// Serves GET /auth/connect/:code: `code` redeems once for `token` (send it as the password); others answer 401.
pairing?: { code: string; token: string }
}
// The page relays events as opaque values; benchmarks also push their own payloads.
type MockStream = { push: (payloads: readonly unknown[]) => void }
type MockStreamState = {
controller?: ReadableStreamDefaultController<Uint8Array>
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.
__testSseTransports?: Record<string, { command: (input: MockSendCommand) => void }>
// Per-origin mock event streams; in-page benchmark probes push through them directly.
__mockServerStreams?: Record<string, MockStream>
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const server = config.server ?? SERVER
mockedOrigins(page).add(server)
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: 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<Uint8Array> | undefined
let keepalive: ReturnType<typeof setInterval> | undefined
const body = new ReadableStream<Uint8Array>({
start(controller) {
own = controller
state.controller = controller
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
controller.enqueue(
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)
request.signal.addEventListener(
"abort",
() => {
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"))
},
{ once: true },
)
},
cancel() {
if (ended) return
ended = true
clearInterval(keepalive)
if (state.controller === own) state.controller = undefined
},
})
return Promise.resolve(
new Response(body, {
status: 200,
headers: { "cache-control": "no-cache", "content-type": "text/event-stream" },
}),
)
}
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
},
{ server, retry: config.eventRetry, keepalive: config.keepalive !== false },
)
// 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 MockServerEvent[]) =>
page.evaluate<void, { server: string; payloads: readonly unknown[] }>(
({ 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 },
)
// Server-side events the mock publishes itself; delivery failures other than a missing document fail the test.
const emit = (events: MockServerEvent[]) =>
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 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()
void push(batch)
.then(
() => {
pump.pending.splice(0, batch.length)
},
(cause: unknown) => {
if (page.isClosed()) return clearInterval(timer)
if (retryableDelivery(cause)) return
clearInterval(timer)
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}`)}`
) {
return route.fulfill({
status: 401,
headers: corsHeaders,
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(),
headers: route.request().headers(),
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"')) {
const request = `${route.request().method()} ${url.pathname}`
await route.fulfill({
status: 501,
headers: corsHeaders,
json: { name: "MockUnsupported", message: `The mock server has no route for ${request}` },
})
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 },
body: payload,
})
})
if (config.pairing) {
const pairing = { ...config.pairing, redeemed: false }
await page.route(`${server}/auth/connect/*`, (route) => {
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers: corsHeaders })
const code = decodeURIComponent(new URL(route.request().url()).pathname.split("/").at(-1) ?? "")
if (code !== pairing.code || pairing.redeemed) {
return route.fulfill({
status: 401,
headers: corsHeaders,
json: Schema.encodeSync(MockUnauthorized)(
new MockUnauthorized({ message: "Pairing link expired or already used" }),
),
})
}
pairing.redeemed = true
return route.fulfill({ headers: corsHeaders, json: { token: pairing.token } })
})
}
const host = new URL(server).host
await page.routeWebSocket(
(url) => url.host === host && /^\/api\/experimental\/persistent-pty\/[^/]+\/connect$/.test(url.pathname),
(ws) => transport.persistentPty.connect(ws),
)
return { server, persistentPty: transport.persistentPty, push }
}
// Answers every Location-scoped request for `directory` as the server does once that folder is deleted.
export function mockMissingLocation(page: Page, directory: string) {
return page.route(
(url) => url.pathname.startsWith("/api/") && url.searchParams.get("location[directory]") === directory,
(route) =>
route.fulfill({
status: 404,
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
headers: { "access-control-allow-origin": "*" },
}),
)
}
// Answers the first model list as a cold Location does when its plugins outlast the server's wait.
export function mockPluginsActivating(page: Page) {
const pending = { model: true }
return page.route(/\/api\/model(\?|$)/, (route) => {
if (!pending.model) return route.fallback()
pending.model = false
return route.fulfill({
status: 503,
// SAFETY: the server's wire body for a plugin wait timeout, which the client decodes into its own error.
json: {
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
_tag: "ServiceUnavailableError",
message: "Location plugins did not finish activating",
service: "plugin",
},
headers: { "access-control-allow-origin": "*" },
})
})
}
// Mocks several servers on one page. Each origin gets its own handler and its own SSE transport for events.
export async function mockServers(page: Page, servers: Record<string, Omit<MockServerConfig, "server" | "events">>) {
return Object.fromEntries(
await Promise.all(
Object.entries(servers).map(async ([origin, config]) => {
const transport = await installSseTransport(page, {
server: origin,
retry: config.eventRetry,
keepalive: config.keepalive,
})
const mock = await mockOpenCodeServer(page, { ...config, server: origin })
return [origin, { transport, persistentPty: mock.persistentPty }] as const
}),
),
)
}
// `emit` publishes the events a real server sends after a mutation (without a page, nothing is published).
export function createMockServerHandler(
config: MockServerConfig,
emit: (events: MockServerEvent[]) => void = () => {},
) {
const persistentPty = createPersistentPty({ config: config.persistentPty, directory: config.directory }, emit)
const web = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(
mockHandlers(config, {
cursors: new Map<string, string>(),
nextCursor: 0,
sessionCreates: 0,
persistentPty,
emit,
mcp: new Map<string, MockMcpStatus>(),
attempts: new Map<string, number>(),
}),
),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
return { ...web, persistentPty }
}
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
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<Page, Set<string>>()
function mockedOrigins(page: Page) {
const found = registeredOrigins.get(page)
if (found) return found
const created = new Set<string>()
registeredOrigins.set(page, created)
return created
}
// A server answers its own origin. Production builds call the API on the app origin, which the default server also
// 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(cause: unknown) {
const message = cause instanceof Error ? cause.message : String(cause)
return [
"No mock event stream",
"Execution context was destroyed",
"Target page, context or browser has been closed",
].some((text) => message.includes(text))
}
// 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) : [])].flatMap((item) =>
Option.toArray(decodeProject(item)),
)
return new Set(
[
config.directory,
...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]
}
// The requested `location[directory]`; absent or empty (a server-level read) means the server directory.
function requestDirectory(config: MockServerConfig, request: { url: string }) {
return new URL(request.url, "http://localhost").searchParams.get("location[directory]") || config.directory
}
function resolve<T>(value: Resolvable<T>) {
// 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(
config: MockServerConfig,
state: {
cursors: Map<string, string>
nextCursor: number
sessionCreates: number
persistentPty: ReturnType<typeof createPersistentPty>
emit: (events: MockServerEvent[]) => void
// MCP status overrides by `<directory>\n<server>`, and OAuth attempt creation times by attempt ID.
mcp: Map<string, MockMcpStatus>
attempts: Map<string, number>
},
) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
const configEntries = config.configEntries ?? []
// Without a persistent PTY config the mock is a server with a working daemon and no terminals, since the desktop
// lists a session's terminals whenever its side panel renders.
const persistentPtyEnabled = Effect.suspend(() =>
state.persistentPty.capability !== false
? Effect.void
: Effect.fail(new MockServiceUnavailable({ message: "opencode-pty is not available", service: "opencode-pty" })),
)
const findPersistentPty = (id: string) =>
persistentPtyEnabled.pipe(
Effect.andThen(() =>
Effect.suspend(() => {
const found = state.persistentPty.find(id)
return found
? Effect.succeed(found)
: Effect.fail(new MockPtyNotFound({ ptyID: id, message: `PTY session not found: ${id}` }))
}),
),
)
const mcpServers = (directory: string) =>
(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) => 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)))
if (Predicate.isString(value)) return HttpServerResponse.uint8Array(new TextEncoder().encode(value))
// Like the server, stream base64 content as the bytes it encodes.
return HttpServerResponse.uint8Array(
value?.encoding === "base64"
? Buffer.from(value.content, "base64")
: new TextEncoder().encode(value?.content ?? ""),
)
}),
)
// Raw, so a hook's answer can replace the status and body.
.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.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: `${payload?.directory ?? config.directory}/${payload?.name ?? "copy"}` },
}
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 = 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: 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 })
}),
)
.handleAll({
info: () =>
Effect.succeed({
version: "2.0.0",
pid: 1,
hostname: config.hostname,
urls: config.server ? [config.server] : [],
paths: { tmp: "/tmp/opencode" },
capabilities:
state.persistentPty.capability === undefined
? undefined
: { persistentPty: state.persistentPty.capability },
}),
config: () => Effect.succeed(configEntries),
reference: () => Effect.succeed({ location: location(config), data: [] }),
agent: (ctx) =>
Effect.succeed({
location: location(config, requestDirectory(config, ctx.request)),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
}),
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(resolve(config.provider)) }),
integrationList: () =>
Effect.sync(() => ({ location: location(config), data: resolve(config.integrations ?? []) })),
integrationGet: (ctx) =>
Effect.sync(() => ({
location: location(config),
data: resolve(config.integrations ?? [])
.filter(Predicate.isObject)
.find((integration) => integration.id === ctx.params.integrationID) ?? {
id: ctx.params.integrationID,
name: ctx.params.integrationID,
methods: [{ type: "key", label: "API key" }],
connections: [],
},
})),
integrationConnect: (ctx) =>
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
Effect.andThen(noContent),
),
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: {
attemptID,
url: started.url,
instructions: "",
mode: "auto",
time: { created, expires: created + 600_000 },
},
}
})
},
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: config.onIntegrationOAuthStatus?.(ctx.params.attemptID) ?? "pending",
time: { created, expires: created + 600_000 },
},
})
}),
credentialActivate: (ctx) =>
Effect.sync(() => config.onCredentialActivate?.(ctx.params.credentialID)).pipe(Effect.andThen(noContent)),
credentialUpdate: (ctx) =>
Effect.sync(() => config.onCredentialUpdate?.(ctx.params.credentialID, ctx.payload)).pipe(
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: (ctx) =>
Effect.sync(() => ({
location: location(config, requestDirectory(config, ctx.request)),
data: resolve(config.commands ?? []),
})),
skill: () => Effect.sync(() => ({ location: location(config), data: resolve(config.skills ?? []) })),
plugin: () => Effect.sync(() => ({ location: location(config), data: resolve(config.plugins ?? []) })),
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),
mcpDisconnect: (ctx) => mcpAction(ctx.params.server, "disconnect", ctx.request),
mcpResource: (ctx) =>
Effect.succeed({
location: location(config, requestDirectory(config, ctx.request)),
data: { resources: [], templates: [] },
}),
projectList: () =>
Effect.sync(() => {
if (config.projects) return resolve(config.projects)
const seed = projectSeed(config)
return [{ ...projectFields(config), canonical: seed?.canonical ?? seed?.worktree ?? config.directory }]
}),
projectUpdate: (ctx) =>
Effect.succeed({
...projectFields(config),
...ctx.payload,
id: ctx.params.projectID,
canonical: projectSeed(config)?.canonical ?? config.directory,
}),
configShells: () => Effect.succeed(config.shells ?? []),
configUpdate: () => noContent,
websearchProviders: () => Effect.succeed({ location: location(config), data: [] }),
// No scenario signs in to an OpenCode Console workspace, so none has usage limits.
usageLimits: () => Effect.succeed({ location: location(config), data: null }),
worktreeList: () =>
Effect.sync(() => {
if (config.worktrees) return resolve(config.worktrees)
return [
{ directory: config.directory },
...(projectSeed(config)?.sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
]
}),
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.
worktreeRefresh: () => noContent,
location: (ctx) =>
Effect.sync(() => {
const requested = new URL(ctx.request.url, "http://localhost").searchParams.get("location[directory]")
if (requested) config.onLocationGet?.(requested)
return location(config, requested || config.directory)
}),
permissionRequests: () =>
Effect.suspend(() =>
config.permissionListFailures?.()
? Effect.fail(new MockInternal({ message: "Permission list failed" }))
: Effect.succeed({
location: location(config),
data: currentPermissions(resolve(config.permissions ?? [])),
}),
),
formRequests: () => Effect.succeed({ location: location(config), data: resolve(config.forms ?? []) }),
vcs: (ctx) =>
Effect.succeed({
location: location(config, requestDirectory(config, ctx.request)),
data: {
branch: Predicate.isFunction(config.vcs)
? config.vcs(requestDirectory(config, ctx.request))
: (config.vcs ?? { current: "main", default: "main" }),
},
}),
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([
{
id: "evt_vcs_initialized",
type: "worktree.updated",
created: Date.now(),
data: { projectID: project.id },
},
])
}).pipe(Effect.andThen(noContent))
},
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsBranches: (ctx) => {
const query = new URL(ctx.request.url, "http://localhost").searchParams
const search = query.get("search") ?? ""
const limit = Number(query.get("limit") ?? 50)
return Effect.promise(() =>
Promise.resolve(
config.branches?.({
directory: requestDirectory(config, ctx.request),
search,
limit,
}) ?? ["main"],
),
).pipe(
Effect.map((items) => ({
location: location(config, requestDirectory(config, ctx.request)),
data: items.filter((branch) => branch.toLowerCase().includes(search.toLowerCase())).slice(0, limit),
})),
)
},
vcsDiff: (ctx) =>
Effect.sync(() => ({
location: location(config),
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(
Effect.map((data) => ({ location: location(config), data })),
),
fsFind: (ctx) =>
Effect.promise(() =>
Promise.resolve(
config.findFiles?.({
query: ctx.query.query ?? "",
dirs: ctx.query.type,
limit: ctx.query.limit,
directory: requestDirectory(config, ctx.request),
}),
),
).pipe(
Effect.map((entries) => ({
location: location(config, requestDirectory(config, ctx.request)),
data: Array.isArray(entries)
? entries.map((entry) =>
Predicate.isString(entry)
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${requestDirectory(config, ctx.request)}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
: entries,
})),
),
fsWatch: () => Effect.void,
fsUnwatch: () => Effect.void,
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 } }
})
},
shell: (ctx) =>
Effect.sync(() => ({
location: location(config, requestDirectory(config, ctx.request)),
data: resolve(config.shellCommands ?? []),
})),
shellOutput: (ctx) =>
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}` }),
)
const bytes = new TextEncoder().encode(output)
const query = new URL(ctx.request.url, "http://localhost").searchParams
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: {
output: new TextDecoder().decode(bytes.subarray(cursor, end)),
cursor: end,
size: bytes.length,
truncated: false,
},
})
}),
// Like the server, which also answers 204 for a shell that already ended.
shellRemove: () => noContent,
persistentPtyList: (ctx) =>
persistentPtyEnabled.pipe(Effect.map(() => ({ data: state.persistentPty.list(ctx.params.sessionID) }))),
persistentPtyCreate: (ctx) =>
persistentPtyEnabled.pipe(
Effect.map(() => {
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
const directory = session ? currentSession(session, config.directory).location.directory : undefined
return {
data: state.persistentPty.create(ctx.params.sessionID, {
...ctx.payload,
cwd: ctx.payload.cwd ?? directory,
}),
}
}),
),
persistentPtyGet: (ctx) => findPersistentPty(ctx.params.ptyID).pipe(Effect.map((data) => ({ data }))),
persistentPtyUpdate: (ctx) =>
findPersistentPty(ctx.params.ptyID).pipe(
Effect.flatMap((found) =>
ctx.payload.title !== undefined && !state.persistentPty.renameSupported
? Effect.fail(
new MockPtyFeatureUnsupported({
feature: "rename",
message: "Terminal renaming will be available after OpenCode's terminal service restarts",
}),
)
: Effect.sync(() => ({ data: state.persistentPty.update(found.id, ctx.payload) })),
),
),
persistentPtyRemove: (ctx) =>
findPersistentPty(ctx.params.ptyID).pipe(
Effect.map((found) => {
state.persistentPty.remove(found.id)
return HttpApiSchema.NoContent.make()
}),
),
persistentPtySnapshot: (ctx) =>
findPersistentPty(ctx.params.ptyID).pipe(
Effect.map((found) => ({ data: state.persistentPty.snapshot(found.id) })),
),
persistentPtyConnectToken: (ctx) =>
findPersistentPty(ctx.params.ptyID).pipe(
Effect.map((found) => ({
data: {
ticket: state.persistentPty.issue(found.id, Object.fromEntries(Object.entries(ctx.request.headers))),
expires_in: 60,
},
})),
),
sessionList: (ctx) => {
const sessions = config.sessions
.filter(
(session) =>
!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) =>
ctx.query.search === undefined
? true
: String(session.title ?? "")
.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 },
})
},
// The goal plugin's pill reads each routed session's goal; no scenario sets one.
rpcCall: (ctx) =>
ctx.params.rpcID === "opencode.goal" && ctx.params.method === "get"
? Effect.succeed({ output: null })
: Effect.fail(
new MockUnsupported({
message: `The mock server has no ${ctx.params.rpcID}.${ctx.params.method} RPC`,
}),
),
sessionActive: () => {
const status = resolve(config.sessionStatus ?? {})
return Effect.succeed({
data: Object.fromEntries(
[
...Object.entries(status).flatMap(([id, value]) => (value.type === "idle" ? [] : [id])),
...Object.entries(config.sessionPermissions ?? {}).flatMap(([id, list]) =>
currentPermissions(list).length > 0 && status[id]?.type !== "idle" ? [id] : [],
),
...resolve(config.forms ?? []).flatMap((form) =>
Predicate.isObject(form) &&
Predicate.isString(form.sessionID) &&
status[form.sessionID]?.type !== "idle"
? [form.sessionID]
: [],
),
].map((id) => [id, { type: "running" as const }]),
),
})
},
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,
sessionView: () => noContent,
sessionForm: (ctx) =>
Effect.succeed({
data: resolve(config.forms ?? []).filter(
(form) => Predicate.isObject(form) && form.sessionID === ctx.params.sessionID,
),
}),
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: (ctx) =>
Effect.sync(() => config.onBackground?.({ sessionID: ctx.params.sessionID })).pipe(Effect.andThen(noContent)),
sessionInbox: (ctx) =>
Effect.sync(() => ({
data: resolve(config.inbox ?? []).filter(
(item) => Predicate.isObject(item) && item.sessionID === ctx.params.sessionID,
),
})),
sessionPrompt: (ctx) =>
Effect.sync(() => {
const body = Option.getOrElse(decodeJsonObject(ctx.payload), () => ({}))
config.onPrompt?.({ sessionID: ctx.params.sessionID, body })
const prompt = Option.getOrUndefined(decodePromptRequest(body))
return {
data: {
id: prompt?.id ?? `inb_mock_${Date.now()}`,
sessionID: ctx.params.sessionID,
time: { created: Date.now() },
type: "user",
// Keys the request omits stay omitted.
payload: { text: "", ...Option.getOrUndefined(decodePromptPayload(body)) },
delivery: prompt?.delivery ?? "steer",
},
}
}),
// Like the server, a compaction is admitted as a steered inbox item under the proposed ID.
sessionCompact: (ctx) =>
Effect.sync(() => {
const body = Option.getOrElse(decodeJsonObject(ctx.payload), (): Schema.JsonObject => ({}))
config.onCompact?.({ sessionID: ctx.params.sessionID, body })
return {
data: {
id: Predicate.isString(body.id) ? body.id : `inb_mock_${Date.now()}`,
sessionID: ctx.params.sessionID,
time: { created: Date.now() },
type: "compaction",
payload: {},
delivery: "steer",
},
}
}),
sessionGenerate: (ctx) =>
Effect.promise(async () => ({
data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? {
text: "Side-question answer",
},
})),
sessionInboxCancel: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxUpdate: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({
sessionID: ctx.params.sessionID,
inboxID: ctx.params.inboxID,
action: ctx.payload.delivery,
}),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
// Only the session's own list; location requests (`permissions`) come from `/api/permission/request`.
sessionPermission: (ctx) =>
Effect.sync(() => ({
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] ?? [], resolve(config.permissions ?? [])]
pending.forEach((list) => {
const index = list.findIndex((item) => Predicate.isObject(item) && item.id === permissionID)
if (index >= 0) list.splice(index, 1)
})
state.emit([
{
id: `evt_permission_replied_${permissionID}`,
created: Date.now(),
type: "permission.replied",
location: { directory: requestDirectory(config, ctx.request) },
data: {
sessionID,
requestID: permissionID,
reply: Option.getOrUndefined(decodePermissionReply(ctx.payload))?.decision ?? "once",
},
},
])
}).pipe(Effect.andThen(noContent))
},
sessionRename: (ctx) =>
Effect.sync(() => {
const title = Option.getOrUndefined(decodeSessionRename(ctx.payload))?.title
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
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),
)
},
// 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 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 } }),
)
},
sessionRevertClear: () => noContent,
sessionRevertCommit: () => noContent,
messageGet: (ctx) =>
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 },
}
})
},
}),
)
}
// The requested workspace (directory) inside its listed project, else inside the configured project.
function location(config: MockServerConfig, directory = config.directory) {
const listed = (config.projects ? resolve(config.projects) : [])
.flatMap((item) => Option.toArray(decodeProject(item)))
.find((item) =>
[item.canonical ?? item.worktree, ...(item.sandboxes ?? [])].some(
(root) => root !== undefined && (directory === root || directory.startsWith(`${root}/`)),
),
)
const root = listed ? (listed.canonical ?? listed.worktree) : undefined
if (listed && root) return { directory, project: { id: listed.id, directory: root, canonical: root } }
return {
directory,
project: { id: projectSeed(config)?.id, directory: config.directory, canonical: config.directory },
}
}
// The project fields the mock reads.
function projectSeed(config: MockServerConfig) {
return Option.getOrUndefined(decodeProject(config.project))
}
// 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,
canonical: provider.canonical,
integrationID: provider.integrationID,
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: model.capabilities ?? { tools: true, input: ["text"], output: ["text"] },
variants: Object.entries(model.variants ?? {}).map(([id, settings]) =>
Predicate.isObject(settings) ? { id, settings } : { id },
),
time: model.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
)
}
// 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,
},
]
})
}
export function currentSession(session: MockSession, fallbackDirectory?: string) {
const seed = decodeSession(session)
return {
id: session.id,
parentID: session.parentID,
projectID: session.projectID ?? "project",
agent: session.agent ?? "build",
model: session.model,
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...decodeSessionOutcome(session),
time: { created: seed.time?.created ?? 0, updated: seed.time?.updated ?? 0, ...seed.time },
title: session.title ?? session.id,
location: { directory: seed.location?.directory ?? seed.directory ?? fallbackDirectory },
subpath: session.subpath ?? session.path,
revert: session.revert,
}
}
// An optional field that decodes as absent when its value is invalid, so one bad field does not discard the rest.
function lenient<S extends Schema.Top>(schema: S) {
return Schema.optionalKey(schema.pipe(Schema.catchDecoding(() => Effect.succeedNone)))
}
const decodeJsonObject = Schema.decodeUnknownOption(Schema.JsonObject)
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,
canonical: lenient(Schema.String),
integrationID: lenient(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) })),
capabilities: lenient(Model.Capabilities),
variants: lenient(Schema.Record(Schema.String, Schema.Unknown)),
time: lenient(Schema.Struct({ released: Schema.Number })),
}),
)
// 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) }))