mirror of
https://github.com/Heretek-AI/opencode.git
synced 2026-11-09 17:16:05 -10:00
1531 lines
61 KiB
TypeScript
1531 lines
61 KiB
TypeScript
import type { Page } from "@playwright/test"
|
|
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,
|
|
MockUnauthorized,
|
|
MockUnsupported,
|
|
} from "./mock-api"
|
|
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
|
|
provider: Resolvable<MockProviderCatalog>
|
|
integrations?: unknown[]
|
|
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]`.
|
|
projects?: Resolvable<unknown[]>
|
|
sessions: MockSession[]
|
|
pageMessages: (
|
|
sessionId: string,
|
|
limit: number,
|
|
before?: string,
|
|
) => {
|
|
items: SessionMessageInfo[]
|
|
cursor?: string
|
|
}
|
|
vcs?: { current: string; default: 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 }
|
|
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 }) => 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
|
|
// Serves `/api/pty*` and mock PTY WebSockets. Created IDs are the first unused `${prefix}<n>` (prefix must start with "pty").
|
|
// `directory` is the owning workspace (Location); `cwd` is the reported working directory (default: `directory`).
|
|
pty?: { prefix?: string; initial?: { id: string; title: string; directory?: string; cwd?: string }[] }
|
|
// 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>
|
|
}
|
|
|
|
export type MockPtyInfo = {
|
|
id: string
|
|
title: string
|
|
command: string
|
|
args: string[]
|
|
cwd: string
|
|
status: "running" | "exited"
|
|
pid: number
|
|
}
|
|
|
|
export type MockPtySocket = { id: string; url: URL; input: string[]; closed: boolean; send(data: string): void }
|
|
|
|
export type MockPty = {
|
|
list: MockPtyInfo[]
|
|
created: MockPtyInfo[]
|
|
removed: string[]
|
|
updates: { id: string; body: unknown }[]
|
|
tokens: { id: string; headers: Record<string, string>; ticket: string }[]
|
|
sockets: MockPtySocket[]
|
|
// WebSockets closed with 1008 because the PTY, its workspace, or an unused issued ticket did not match.
|
|
rejected: { id: string; url: URL; reason: string }[]
|
|
// Writes output to the newest open socket, optionally for one PTY.
|
|
send(data: string, id?: string): 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<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 OpenCodeEvent[]) =>
|
|
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: OpenCodeEvent[]) =>
|
|
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.pty) {
|
|
const host = new URL(server).host
|
|
await page.routeWebSocket(
|
|
(url) => url.host === host && /^\/api\/pty\/[^/]+\/connect$/.test(url.pathname),
|
|
(ws) => {
|
|
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,
|
|
input: [],
|
|
closed: false,
|
|
send: (data) => ws.send(data),
|
|
}
|
|
|
|
ws.onMessage((message) => socket.input.push(message.toString()))
|
|
ws.onClose(() => {
|
|
socket.closed = true
|
|
})
|
|
transport.pty.sockets.push(socket)
|
|
},
|
|
)
|
|
}
|
|
|
|
return { server, pty: transport.pty, push }
|
|
}
|
|
|
|
// 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, pty: mock.pty }] 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: OpenCodeEvent[]) => void = () => {}) {
|
|
const pty = createPty(config)
|
|
|
|
const web = HttpRouter.toWebHandler(
|
|
HttpApiBuilder.layer(MockApi).pipe(
|
|
Layer.provide(
|
|
mockHandlers(config, {
|
|
cursors: new Map<string, string>(),
|
|
nextCursor: 0,
|
|
sessionCreates: 0,
|
|
pty,
|
|
emit,
|
|
mcp: new Map<string, MockMcpStatus>(),
|
|
attempts: new Map<string, number>(),
|
|
}),
|
|
),
|
|
Layer.provide(HttpServer.layerServices),
|
|
),
|
|
{ disableLogger: true },
|
|
)
|
|
|
|
return { ...web, pty }
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
const PTY_TICKET = "e2e-ticket"
|
|
|
|
function createPty(config: MockServerConfig) {
|
|
const info = (id: string, title: string, cwd = config.directory): MockPtyInfo => ({
|
|
id,
|
|
title,
|
|
command: "cmd.exe",
|
|
args: [],
|
|
cwd,
|
|
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: [],
|
|
removed: [],
|
|
updates: [],
|
|
tokens: [],
|
|
sockets: [],
|
|
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}<n>` 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) {
|
|
owners.set(created.id, directory)
|
|
pty.created.push(created)
|
|
pty.list.push(created)
|
|
},
|
|
find: (id: string, directory: string) => pty.list.find((item) => item.id === id && owns(item, directory)),
|
|
owned: (directory: string) => pty.list.filter((item) => owns(item, directory)),
|
|
// Unique per server; the first stays `e2e-ticket` so single-terminal specs can assert a fixed value.
|
|
issue(id: string, directory: string) {
|
|
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)
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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
|
|
pty: ReturnType<typeof createPty>
|
|
emit: (events: OpenCodeEvent[]) => 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 ?? []
|
|
|
|
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(
|
|
Effect.andThen(() =>
|
|
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) =>
|
|
(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)))
|
|
const content = Predicate.isString(value) ? value : (value?.content ?? "")
|
|
|
|
return HttpServerResponse.uint8Array(new TextEncoder().encode(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,
|
|
urls: config.server ? [config.server] : [],
|
|
paths: { tmp: "/tmp/opencode" },
|
|
}),
|
|
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.succeed({ location: location(config), data: config.integrations ?? [] }),
|
|
integrationGet: (ctx) =>
|
|
Effect.succeed({
|
|
location: location(config),
|
|
data: 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: "pending", time: { created, expires: created + 600_000 } },
|
|
})
|
|
}),
|
|
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: [] }),
|
|
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(() => location(config, requestDirectory(config, ctx.request))),
|
|
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: () =>
|
|
Effect.succeed({
|
|
location: location(config),
|
|
data: { branch: 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: () => Effect.succeed({ location: location(config), data: ["main"] }),
|
|
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 }),
|
|
),
|
|
).pipe(
|
|
Effect.map((entries) => ({
|
|
location: location(config),
|
|
data: Array.isArray(entries)
|
|
? entries.map((entry) =>
|
|
Predicate.isString(entry)
|
|
? {
|
|
name: entry.split(/[\\/]/).at(-1) ?? entry,
|
|
path: entry,
|
|
absolute: `${config.directory}/${entry}`,
|
|
type: "directory",
|
|
ignored: false,
|
|
}
|
|
: entry,
|
|
)
|
|
: entries,
|
|
})),
|
|
),
|
|
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,
|
|
ptyList: (ctx) =>
|
|
ptyEnabled.pipe(
|
|
Effect.map(() => {
|
|
const directory = requestDirectory(config, ctx.request)
|
|
|
|
return { location: location(config, directory), data: state.pty.owned(directory) }
|
|
}),
|
|
),
|
|
ptyCreate: (ctx) =>
|
|
ptyEnabled.pipe(
|
|
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 }
|
|
}),
|
|
),
|
|
ptyGet: (ctx) =>
|
|
findPty(ctx.params.ptyID, ctx.request).pipe(
|
|
Effect.map((data) => ({ location: location(config, requestDirectory(config, ctx.request)), data })),
|
|
),
|
|
ptyUpdate: (ctx) =>
|
|
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 }
|
|
}),
|
|
),
|
|
ptyRemove: (ctx) =>
|
|
findPty(ctx.params.ptyID, ctx.request).pipe(
|
|
Effect.map((found) => {
|
|
state.pty.removed.push(found.id)
|
|
state.pty.list.splice(state.pty.list.indexOf(found), 1)
|
|
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
),
|
|
ptyConnectToken: (ctx) =>
|
|
findPty(ctx.params.ptyID, ctx.request).pipe(
|
|
Effect.map((found) => {
|
|
const directory = requestDirectory(config, ctx.request)
|
|
const ticket = state.pty.issue(found.id, directory)
|
|
state.pty.tokens.push({
|
|
id: found.id,
|
|
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) =>
|
|
!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 },
|
|
})
|
|
},
|
|
sessionActive: () =>
|
|
Effect.succeed({
|
|
data: Object.fromEntries(
|
|
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) =>
|
|
Effect.succeed({
|
|
data: resolve(config.forms ?? []).filter(
|
|
(form) => Predicate.isObject(form) && form.sessionID === ctx.params.sessionID,
|
|
),
|
|
}),
|
|
sessionFormReply: () => noContent,
|
|
sessionFormCancel: () => noContent,
|
|
sessionBackground: () => 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 the configured project.
|
|
function location(config: MockServerConfig, directory = config.directory) {
|
|
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,
|
|
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
|
|
)
|
|
}
|
|
|
|
// 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 ?? { id: "mock-model", providerID: "mock-provider" },
|
|
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 }))
|
|
|
|
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) }))
|