diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 51f1611702..f0837b3694 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -23,6 +23,7 @@ import { HttpRouter } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" +import { TestLLMServer } from "../test/lib/llm-server" import type { Config } from "../src/config/config" import { MessageID, PartID, type SessionID } from "../src/session/schema" import { ModelID, ProviderID } from "../src/provider/schema" @@ -52,7 +53,7 @@ type Method = (typeof Methods)[number] type OpenApiMethod = (typeof OpenApiMethods)[number] type Mode = "effect" | "parity" | "coverage" type Comparison = "none" | "status" | "json" -type ProjectOptions = { git?: boolean; config?: Partial } +type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } type OpenApiSpec = { paths?: Record>> } type JsonObject = Record @@ -89,6 +90,8 @@ type ScenarioContext = { todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect worktree: (input?: { name?: string }) => Effect.Effect worktreeRemove: (directory: string) => Effect.Effect + llmText: (value: string) => Effect.Effect + llmWait: (count: number) => Effect.Effect } /** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ @@ -218,6 +221,10 @@ class ScenarioBuilder { return this.clone({ project }) } + withLlm() { + return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) + } + at(request: BuilderState["request"]) { return this.clone({ request }) } @@ -672,6 +679,34 @@ const scenarios: Scenario[] = [ .json(200, (body) => { check(body === true, "abort should return true") }, "status"), + http + .post("/session/{sessionID}/message", "session.prompt") + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "LLM prompt session" }) + yield* ctx.llmText("fake assistant") + yield* ctx.llmText("fake assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), "assistant message should use fake LLM text") + yield* ctx.llmWait(1) + }), + "status"), http .post("/session/{sessionID}/revert", "session.revert") .mutating() @@ -779,7 +814,6 @@ const scenarios: Scenario[] = [ pending("POST", "/session/{sessionID}/share", "session.share", "hits sharing service; needs share fixture"), pending("DELETE", "/session/{sessionID}/share", "session.unshare", "hits sharing service; needs share fixture"), pending("POST", "/session/{sessionID}/summarize", "session.summarize", "invokes compaction/LLM flow; needs test LLM wiring"), - pending("POST", "/session/{sessionID}/message", "session.prompt", "streams LLM prompt output; needs test LLM streaming probe"), pending("POST", "/session/{sessionID}/prompt_async", "session.prompt_async", "starts async LLM prompt; needs test LLM wiring"), pending("POST", "/session/{sessionID}/command", "session.command", "invokes LLM command flow; needs test LLM wiring"), pending("POST", "/session/{sessionID}/shell", "session.shell", "invokes session shell/LLM flow; needs test LLM wiring"), @@ -809,8 +843,8 @@ const main = Effect.gen(function* () { }) function runScenario(options: Options) { - return (scenario: Scenario): Effect.Effect => { - if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario }) + return (scenario: Scenario) => { + if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) return runActive(options, scenario).pipe( Effect.as({ status: "pass", scenario } as Result), Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), @@ -853,25 +887,37 @@ function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { return Effect.acquireRelease( - Effect.promise(async () => (scenario.project ? (await runtime()).tmpdir(scenario.project) : undefined)), - (dir) => Effect.promise(async () => void (await dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), + Effect.gen(function* () { + const llm = scenario.project?.llm ? yield* TestLLMServer : undefined + const project = scenario.project + const dir = project + ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) + : undefined + return { dir, llm } + }), + (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), ).pipe( - Effect.flatMap((dir) => Effect.gen(function* () { + Effect.flatMap((context) => Effect.gen(function* () { const modules = yield* Effect.promise(() => runtime()) - const instance = dir?.path - ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: dir.path })).pipe( + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( Effect.provide(modules.AppLayer), ) : undefined const run = (effect: Effect.Effect) => effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) const directory = () => { - if (!dir?.path) throw new Error("scenario needs a project directory") - return dir.path + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm } const base: ScenarioContext = { - directory: dir?.path, - headers: (extra) => ({ ...(dir?.path ? { "x-opencode-directory": dir.path } : {}), ...extra }), + directory: context.dir?.path, + headers: (extra) => ({ ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), ...extra }), file: (name, content) => Effect.promise(() => { return Bun.write(`${directory()}/${name}`, content) @@ -925,14 +971,65 @@ function withContext(scenario: ActiveScenario, use: (ctx: SeededContext svc.create(input))), worktreeRemove: (directory) => run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), } const state = yield* scenario.seed(base) return yield* use({ ...base, state }) - })), + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void))), Effect.ensuring(resetState), ) } +function projectOptions(project: ProjectOptions, llmUrl: string | undefined): { git?: boolean; config?: Partial } { + if (!project.llm || !llmUrl) return { git: project.git, config: project.config } + const fake = fakeLlmConfig(llmUrl) + return { + git: project.git, + config: { + ...fake, + ...project.config, + provider: { + ...fake.provider, + ...project.config?.provider, + }, + }, + } +} + +function fakeLlmConfig(url: string): Partial { + return { + model: "test/test-model", + small_model: "test/test-model", + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + function call(backend: "effect" | "legacy", scenario: ActiveScenario, ctx: SeededContext) { return Effect.promise(async () => capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)))) } @@ -1124,7 +1221,7 @@ function indent(value: string) { .join("\n") } -Effect.runPromise(main).then( +Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( () => process.exit(0), (error: unknown) => { console.error(`${color.red}${message(error)}${color.reset}`)