From ecfa918760ac44b64c50477954f1c6948a2ca375 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 29 Jun 2026 19:21:18 -0400 Subject: [PATCH] feat(client): expose fs read in promise client (#34504) --- packages/client/script/build.ts | 9 +- packages/client/src/contract.ts | 3 +- packages/client/src/generated/client.ts | 21 ++++ packages/client/src/generated/types.ts | 9 ++ packages/client/test/promise.test.ts | 23 +++- packages/core/src/shell.ts | 43 ++++--- packages/core/src/tool/builtins.ts | 10 +- packages/core/test/location-layer.test.ts | 2 - packages/httpapi-codegen/src/index.ts | 119 ++++++++++++++---- .../httpapi-codegen/test/generate.test.ts | 51 +++++--- 10 files changed, 227 insertions(+), 63 deletions(-) diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index aeec4b3e345..287e6fccd9c 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -1,16 +1,17 @@ import { NodeFileSystem } from "@effect/platform-node" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" -import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract" +import { ClientApi, effectOmitEndpoints, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract" import { Effect } from "effect" import { fileURLToPath } from "url" -const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints }) +const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) +const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints }) await Effect.runPromise( Effect.all( [ write( - emitPromise(contract, { + emitPromise(promiseContract, { outputTypes: { "events.subscribe": { name: "OpenCodeEventEncoded", @@ -21,7 +22,7 @@ await Effect.runPromise( fileURLToPath(new URL("../src/generated", import.meta.url)), ), write( - emitEffectImported(contract, { module: "../contract", api: "ClientApi" }), + emitEffectImported(effectContract, { module: "../contract", api: "ClientApi" }), fileURLToPath(new URL("../src/generated-effect", import.meta.url)), ), ], diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 61dda7c6107..32f85abb976 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -52,4 +52,5 @@ export const endpointNames = { "question.request.list": "listRequests", } as const -export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) +export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"]) +export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index f7825f55fa9..8a830f49b72 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -87,6 +87,8 @@ import type { PermissionsGetOutput, PermissionsReplyInput, PermissionsReplyOutput, + FilesReadInput, + FilesReadOutput, FilesListInput, FilesListOutput, FilesFindInput, @@ -155,6 +157,7 @@ interface RequestDescriptor { readonly successStatus: number readonly declaredStatuses: ReadonlyArray readonly empty: boolean + readonly binary?: true } export function make(options: ClientOptions) { @@ -200,6 +203,7 @@ export function make(options: ClientOptions) { const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { const response = await execute(descriptor, requestOptions) if (response.status !== descriptor.successStatus) return responseError(response, descriptor) + if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A if (descriptor.empty) { try { await response.body?.cancel() @@ -840,6 +844,19 @@ export function make(options: ClientOptions) { ), }, files: { + read: (input: FilesReadInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/fs/read/${encodePath(input.path)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + binary: true, + }, + requestOptions, + ), list: (input?: FilesListInput, requestOptions?: RequestOptions) => request( { @@ -1143,6 +1160,10 @@ export function make(options: ClientOptions) { } } +function encodePath(value: string): string { + return value.split("/").map(encodeURIComponent).join("/") +} + function appendQuery(params: URLSearchParams, key: string, value: unknown): void { if (value === undefined || value === null) return if (Array.isArray(value)) { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 1bada8512c9..a375bc1e7c8 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2572,6 +2572,15 @@ export type PermissionsReplyInput = { export type PermissionsReplyOutput = void +export type FilesReadInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly path: string +} + +export type FilesReadOutput = globalThis.Uint8Array + export type FilesListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index dba04e6706c..d00db17d856 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -37,11 +37,32 @@ test("exposes every standard HTTP API group", () => { "attemptComplete", "attemptCancel", ]) - expect(Object.keys(client.files)).toEqual(["list", "find"]) + expect(Object.keys(client.files)).toEqual(["read", "list", "find"]) expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.project)).toEqual(["current", "directories"]) }) +test("files.read returns binary content from the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + request = input instanceof Request ? input : new Request(input) + return new Response(new Uint8Array([104, 105])) + }, + }) + + const content = await client.files.read({ + path: "src/a b#c.ts", + location: { directory: "/tmp/project" }, + }) + + expect(Array.from(content)).toEqual([104, 105]) + expect(request?.url).toBe( + "http://localhost:3000/api/fs/read/src/a%20b%23c.ts?location%5Bdirectory%5D=%2Ftmp%2Fproject", + ) +}) + test("project methods use the public HTTP contract", async () => { const requests: string[] = [] const client = OpenCode.make({ diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 8d4b2540933..26fcfc4d7ee 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -31,7 +31,7 @@ type Active = { // Resolves with the terminal Info once the command exits, times out, or is killed. A wait // started after termination resolves immediately from the already-completed deferred. done: Deferred.Deferred - timeoutFiber?: Fiber.Fiber + timeoutFiber?: Fiber.Fiber } /** @@ -181,7 +181,7 @@ export const layer = Layer.effect( // Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so // the managing fiber keeps its scope open until the command terminates (it awaits `done` at the // end). `create` returns once `ready` resolves with the registered session. - const ready = Deferred.makeUnsafe() + const ready = Deferred.makeUnsafe() runFork( Effect.scoped( Effect.gen(function* () { @@ -205,14 +205,7 @@ export const layer = Layer.effect( sessions.set(id, session) const stream = createWriteStream(file) - yield* Effect.promise( - () => - new Promise((resolve) => { - stream.once("open", () => resolve()) - stream.once("error", () => resolve()) - }), - ) - + const outputDone = Deferred.makeUnsafe() const pump = handle.all.pipe( Stream.runForEach((chunk: Uint8Array) => Effect.sync(() => { @@ -221,9 +214,27 @@ export const layer = Layer.effect( }), ), ) - runFork(pump.pipe(Effect.catch(() => Effect.void))) + runFork( + Effect.gen(function* () { + yield* pump.pipe(Effect.catch(() => Effect.void)) + yield* Effect.promise( + () => + new Promise((resolve) => { + stream.end(() => resolve()) + }), + ) + yield* Deferred.succeed(outputDone, undefined) + }).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))), + ) + yield* Effect.promise( + () => + new Promise((resolve) => { + stream.once("open", () => resolve()) + stream.once("error", () => resolve()) + }), + ) - const finish = (status: Info["status"], exit?: number) => + const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) => Effect.gen(function* () { if (session.info.status !== "running") return session.info = produce(session.info, (draft) => { @@ -231,7 +242,8 @@ export const layer = Layer.effect( if (exit !== undefined) draft.exit = exit draft.time.completed = Date.now() }) - stream.end() + yield* beforeWait + yield* Deferred.await(outputDone) // Resolve waiters with the terminal Info before any retention eviction, so an evicted // session still reports success rather than the removal NotFoundError. This runs before // the timeout-fiber interrupt below, which on the timeout path would otherwise cancel @@ -257,10 +269,7 @@ export const layer = Layer.effect( session.timeoutFiber = runFork( Effect.sleep(Duration.millis(input.timeout)).pipe( Effect.flatMap(() => - Effect.gen(function* () { - yield* finish("timeout") - yield* handle.kill().pipe(Effect.catch(() => Effect.void)) - }), + finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))), ), Effect.catch(() => Effect.void), ), diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index b69e2edfe7e..08c6b2a1be8 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -1,7 +1,7 @@ export * as BuiltInTools from "./builtins" import { makeLocationNode } from "../effect/app-node" -import { Layer } from "effect" +import { Context, Layer } from "effect" import { ApplyPatchTool } from "./apply-patch" import { EditTool } from "./edit" import { GlobTool } from "./glob" @@ -27,6 +27,8 @@ import { SessionTodo } from "../session/todo" import { ToolRegistry } from "./registry" import { httpClient } from "../effect/app-node-platform" +export class Service extends Context.Service>()("@opencode/v2/BuiltInTools") {} + /** * Composes only the shipped Location-scoped built-in tool transforms. * Each tool retains its implementation and focused tests independently. Dynamic @@ -40,7 +42,7 @@ import { httpClient } from "../effect/app-node-platform" * repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin * transforms separate from this static built-in list. */ -export const locationLayer = Layer.mergeAll( +const registrations = Layer.mergeAll( ApplyPatchTool.layer, EditTool.layer, GlobTool.layer, @@ -54,8 +56,10 @@ export const locationLayer = Layer.mergeAll( WriteTool.layer, ) +export const locationLayer = Layer.succeed(Service, Service.of({})).pipe(Layer.provideMerge(registrations)) + export const node = makeLocationNode({ - name: "built-in-tools", + service: Service, layer: locationLayer, deps: [ ToolRegistry.toolsNode, diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index ad6257a4929..35616256d9b 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -126,7 +126,6 @@ describe("LocationServiceMap", () => { "grep", "question", "read", - "shell", "skill", "todowrite", "webfetch", @@ -143,7 +142,6 @@ describe("LocationServiceMap", () => { "grep", "question", "read", - "shell", "skill", "todowrite", "webfetch", diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 298a1211ddd..43d9e928814 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -9,10 +9,15 @@ export type InputField = { readonly source: "params" | "query" | "headers" | "payload" } +export type OperationInputField = { + readonly name: string + readonly source: InputField["source"] | "wildcard" +} + export type Operation = { readonly group: string readonly name: string - readonly input: ReadonlyArray + readonly input: ReadonlyArray readonly inputMode: "none" | "optional" | "required" readonly success: "value" | "void" | "stream" readonly errors: ReadonlyArray @@ -67,6 +72,10 @@ type Slot = { readonly schema: Schema.Top } +type PromiseInputField = + | (InputField & { readonly optional: boolean }) + | { readonly name: string; readonly source: "wildcard"; readonly optional: false } + const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") const resolveContentSchema = SchemaAST.resolveAt("contentSchema") @@ -246,7 +255,7 @@ export function emitPromise( for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) } return { - operations: operations(groups), + operations: promiseOperations(groups), files: [ { path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) }, { @@ -255,7 +264,7 @@ export function emitPromise( }, { path: "client.ts", - content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult", "let next"), + content: normalizePromiseClientContent(renderPromiseClient(groups), groups), }, { path: "index.ts", @@ -285,11 +294,11 @@ function assertPromiseEndpoint(endpoint: Endpoint) { ) { throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` }) } - } else if ( - !HttpApiSchema.isNoContent(success.ast) && - (resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json" - ) { - throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } else if (!HttpApiSchema.isNoContent(success.ast)) { + const encoding = resolveHttpApiEncoding(success.ast)?._tag ?? "Json" + if (encoding !== "Json" && encoding !== "Uint8Array") { + throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } } for (const error of endpoint.errors) { if (declaredErrorFields(error.schema) === undefined) { @@ -305,6 +314,16 @@ function operations(groups: ReadonlyArray) { return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation)) } +function promiseOperations(groups: ReadonlyArray) { + return groups.flatMap((group) => + group.endpoints.map((endpoint) => ({ + ...endpoint.operation, + input: promiseInput(endpoint).map(({ name, source }) => ({ name, source })), + inputMode: promiseInputMode(endpoint), + })), + ) +} + function renderEffectFiles(groups: ReadonlyArray): Output["files"] { return [ ...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })), @@ -457,8 +476,9 @@ function renderPromiseTypes( headers: endpoint.headers, payload: endpoint.payloads[0], } - const input = endpoint.input + const input = promiseInput(endpoint) .map((field) => { + if (field.source === "wildcard") return `readonly ${JSON.stringify(field.name)}: string` const schema = schemas[field.source] if (schema === undefined) throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) @@ -476,7 +496,7 @@ function renderPromiseTypes( : successSchema, ) return [ - ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), + ...(promiseInputMode(endpoint) === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, ] }), @@ -493,19 +513,19 @@ function renderPromiseClient(groups: ReadonlyArray) { const imports = groups.flatMap((group) => group.endpoints.flatMap((endpoint) => { const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) - return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] + return [...(promiseInputMode(endpoint) === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] }), ) const fields = groups.map((group) => { const methods = group.endpoints.map((endpoint) => { const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const inputMode = promiseInputMode(endpoint) const argument = - endpoint.operation.inputMode === "none" + inputMode === "none" ? "requestOptions?: RequestOptions" - : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` - const path = promisePath(endpoint.endpoint.path, endpoint.input) - const access = (name: string) => - `input${endpoint.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]` + : `input${inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` + const path = promisePath(endpoint.endpoint.path, endpoint.input, promiseWildcardInput(endpoint)) + const access = (name: string) => `input${inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]` const part = (source: InputField["source"]) => { const inputs = endpoint.input.filter((field) => field.source === source) return inputs.length === 0 @@ -518,7 +538,7 @@ function renderPromiseClient(groups: ReadonlyArray) { endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`, ].filter((value): value is string => value !== undefined) const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))] - const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }` + const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""} }` if (endpoint.operation.success === "stream") { const success = endpoint.successes[0] if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") { @@ -583,10 +603,67 @@ function structuralType(schema: Schema.Top) { .replaceAll("Schema.Json", "JsonValue") } -function promisePath(path: string, input: ReadonlyArray) { - if (path.includes("*")) throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${path}` }) +function normalizePromiseClientContent(content: string, groups: ReadonlyArray) { + const endpoints = groups.flatMap((group) => group.endpoints) + const usesBinary = endpoints.some((endpoint) => isBinarySchema(endpoint.successes[0])) + const usesWildcard = endpoints.some((endpoint) => promiseWildcardInput(endpoint) !== undefined) + + const sseReady = replaceOne(content, "let next: ReadableStreamReadResult", "let next") + const binaryReady = usesBinary + ? replaceOne( + replaceOne(sseReady, "readonly empty: boolean\n}", "readonly empty: boolean\n readonly binary?: true\n}"), + "if (descriptor.empty) {", + "if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A\n if (descriptor.empty) {", + ) + : sseReady + return usesWildcard + ? replaceOne( + binaryReady, + "function appendQuery(params: URLSearchParams, key: string, value: unknown): void {", + 'function encodePath(value: string): string {\n return value.split("/").map(encodeURIComponent).join("/")\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {', + ) + : binaryReady +} + +function replaceOne(input: string, search: string, replacement: string) { + if (!input.includes(search)) + throw new GenerationError({ reason: `Missing Promise client template marker: ${search}` }) + return input.replace(search, replacement) +} + +function promiseInput(endpoint: Endpoint): ReadonlyArray { + const wildcard = promiseWildcardInput(endpoint) + if (wildcard === undefined) return endpoint.input + return [...endpoint.input, wildcard] +} + +function promiseInputMode(endpoint: Endpoint): Operation["inputMode"] { + const input = promiseInput(endpoint) + if (input.length === 0) return "none" + return input.every((field) => field.optional) ? "optional" : "required" +} + +function promiseWildcardInput(endpoint: Endpoint): PromiseInputField | undefined { + if (!endpoint.endpoint.path.includes("*")) return undefined + if (endpoint.endpoint.path.indexOf("*") !== endpoint.endpoint.path.lastIndexOf("*")) { + throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${endpoint.endpoint.path}` }) + } + if (!endpoint.endpoint.path.endsWith("*")) { + throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${endpoint.endpoint.path}` }) + } + const name = endpoint.input.some((field) => field.name === "path") ? "wildcard" : "path" + return { name, source: "wildcard", optional: false } +} + +function isBinarySchema(schema: Schema.Top) { + return (resolveHttpApiEncoding(schema.ast)?._tag ?? "Json") === "Uint8Array" +} + +function promisePath(path: string, input: ReadonlyArray, wildcard?: PromiseInputField) { const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name)) - const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean) + const segments = (wildcard === undefined ? path : path.slice(0, -1)) + .split(/(:[A-Za-z_][A-Za-z0-9_]*)/g) + .filter(Boolean) const template = segments .map((segment) => { if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`") @@ -595,7 +672,7 @@ function promisePath(path: string, input: ReadonlyArray) { return `\${encodeURIComponent(input.${name})}` }) .join("") - return `\`${template}\`` + return `\`${template}${wildcard === undefined ? "" : `\${encodePath(input.${wildcard.name})}`}\`` } function uniqueModule(base: string, index: number, modules: ReadonlySet) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 6e076b7ef4e..a5a9d757700 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -355,20 +355,8 @@ describe("HttpApiCodegen.generate", () => { ).toThrow("Unsupported Promise success encoding: session.text") expect(() => - emitPromise( - compileContract( - api( - HttpApiEndpoint.get("binary", "/binary", { - success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), - }), - ), - ), - ), - ).toThrow("Unsupported Promise success encoding: session.binary") - - expect(() => - emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*", { success: Schema.String })))), - ).toThrow("Unsupported Promise path wildcard: /file/*") + emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*/tail", { success: Schema.String })))), + ).toThrow("Unsupported Promise path wildcard: /file/*/tail") expect(() => emitPromise( @@ -443,6 +431,41 @@ describe("HttpApiCodegen.generate", () => { } }) + test("executes an emitted binary wildcard GET through fetch", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("read", "/file/*", { + query: { token: Schema.optional(Schema.String) }, + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + request = input instanceof Request ? input : new Request(input) + return new Response(new Uint8Array([1, 2, 3])) + }, + }) + + const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" }) + expect(result).toBeInstanceOf(Uint8Array) + expect(Array.from(result)).toEqual([1, 2, 3]) + expect(request?.method).toBe("GET") + expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + test("serializes flattened query, header, and JSON payload inputs", async () => { const output = emitPromise( compileContract(