From f584f80219b97ea6b7d66b559cb1dc1f21ab9f01 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 16:29:58 -0400 Subject: [PATCH 01/39] test(httpapi): verify reflected route mounts (#24663) --- .../test/server/httpapi-bridge.test.ts | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index dac23a654d15..4417cc4464c3 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -4,8 +4,23 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { InstanceRoutes } from "../../src/server/routes/instance" import { WorkspaceRoutes } from "../../src/server/routes/control/workspace" -import { FilePaths } from "../../src/server/routes/instance/httpapi/file" +import { ConfigApi } from "../../src/server/routes/instance/httpapi/config" +import { EventPaths } from "../../src/server/routes/instance/httpapi/event" +import { ExperimentalApi } from "../../src/server/routes/instance/httpapi/experimental" +import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file" +import { InstanceApi } from "../../src/server/routes/instance/httpapi/instance" +import { McpApi } from "../../src/server/routes/instance/httpapi/mcp" +import { PermissionApi } from "../../src/server/routes/instance/httpapi/permission" +import { ProjectApi } from "../../src/server/routes/instance/httpapi/project" +import { ProviderApi } from "../../src/server/routes/instance/httpapi/provider" +import { PtyApi, PtyPaths } from "../../src/server/routes/instance/httpapi/pty" +import { QuestionApi } from "../../src/server/routes/instance/httpapi/question" +import { SessionApi } from "../../src/server/routes/instance/httpapi/session" +import { SyncApi } from "../../src/server/routes/instance/httpapi/sync" +import { TuiApi } from "../../src/server/routes/instance/httpapi/tui" +import { WorkspaceApi } from "../../src/server/routes/instance/httpapi/workspace" import * as Log from "@opencode-ai/core/util/log" +import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" @@ -30,6 +45,39 @@ function routeKey(route: ReturnType["routes"][number]) { return `${route.method} ${route.path}` } +function reflectedHttpApiRoutes() { + const routes = [ + `GET ${EventPaths.event}`, + `GET ${PtyPaths.connect}`, + ] + + function addRoutes(api: HttpApi.HttpApi) { + HttpApi.reflect(api, { + onGroup() {}, + onEndpoint({ endpoint }) { + routes.push(`${endpoint.method} ${endpoint.path}`) + }, + }) + } + + addRoutes(ConfigApi) + addRoutes(ExperimentalApi) + addRoutes(FileApi) + addRoutes(InstanceApi) + addRoutes(McpApi) + addRoutes(PermissionApi) + addRoutes(ProjectApi) + addRoutes(ProviderApi) + addRoutes(PtyApi) + addRoutes(QuestionApi) + addRoutes(SessionApi) + addRoutes(SyncApi) + addRoutes(TuiApi) + addRoutes(WorkspaceApi) + + return [...new Set(routes)] +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -69,6 +117,21 @@ describe("HttpApi Hono bridge", () => { expect([...bridgeRoutes].filter((route) => !legacyRoutes.includes(route)).sort()).toEqual([]) }) + test("mounts every Effect HttpApi route through the Hono bridge", () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + const legacy = InstanceRoutes(websocket) + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const experimental = InstanceRoutes(websocket) + + const bridgeRoutes = new Set( + experimental.routes.slice(0, experimental.routes.length - legacy.routes.length).map(routeKey), + ) + const httpApiRoutes = reflectedHttpApiRoutes() + + expect(httpApiRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([]) + expect([...bridgeRoutes].filter((route) => !httpApiRoutes.includes(route)).sort()).toEqual([]) + }) + test("allows requests when auth is disabled", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(`${tmp.path}/hello.txt`, "hello") From 9cd2e3a1c347459addfa818e8ca5a5df922d5e93 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 27 Apr 2026 20:31:05 +0000 Subject: [PATCH 02/39] chore: generate --- packages/opencode/test/server/httpapi-bridge.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 4417cc4464c3..8f9170d6600b 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -46,10 +46,7 @@ function routeKey(route: ReturnType["routes"][number]) { } function reflectedHttpApiRoutes() { - const routes = [ - `GET ${EventPaths.event}`, - `GET ${PtyPaths.connect}`, - ] + const routes = [`GET ${EventPaths.event}`, `GET ${PtyPaths.connect}`] function addRoutes(api: HttpApi.HttpApi) { HttpApi.reflect(api, { From e0f3df8252c93634de7067f7fbc9934d47453700 Mon Sep 17 00:00:00 2001 From: Cas <10153929+CasualDeveloper@users.noreply.github.com> Date: Tue, 28 Apr 2026 04:31:49 +0800 Subject: [PATCH 03/39] fix(tui): consume Enter in dialog useKeyboard handlers (#23390) --- .../opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx | 2 ++ .../src/cli/cmd/tui/component/dialog-session-delete-failed.tsx | 2 ++ packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx | 2 ++ packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx | 2 ++ packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx | 2 ++ packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx | 2 ++ packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx | 2 ++ 7 files changed, 14 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx index ace4b090bca5..b512f9021c37 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx @@ -77,6 +77,8 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { return } if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() if (selected() === "subscribe") subscribe(props, dialog) else dismiss(props, dialog) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx index 4a22a0c492d1..3d3059d9534c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx @@ -42,6 +42,8 @@ export function DialogSessionDeleteFailed(props: { useKeyboard((evt) => { if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() void confirm() } if (evt.name === "left" || evt.name === "up") { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index 642c73b48561..fb159115dc51 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -15,6 +15,8 @@ export function DialogAlert(props: DialogAlertProps) { useKeyboard((evt) => { if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() props.onConfirm?.() dialog.clear() } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index 48adddaedcf5..3870cf816cbb 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -25,6 +25,8 @@ export function DialogConfirm(props: DialogConfirmProps) { useKeyboard((evt) => { if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() if (store.active === "confirm") props.onConfirm?.() if (store.active === "cancel") props.onCancel?.() dialog.clear() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index 513d34910b77..b9362db46b28 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -35,6 +35,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { useKeyboard((evt) => { if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() props.onConfirm?.({ filename: textarea.plainText, thinking: store.thinking, diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index 4e4527930345..24b93b96a77d 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -11,6 +11,8 @@ export function DialogHelp() { useKeyboard((evt) => { if (evt.name === "return" || evt.name === "escape") { + evt.preventDefault() + evt.stopPropagation() dialog.clear() } }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index 6df99c33fd22..92d6d277d0eb 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -29,6 +29,8 @@ export function DialogPrompt(props: DialogPromptProps) { return } if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() props.onConfirm?.(textarea.plainText) } }) From 139c4fd5555022f1a7cecbe1747d2fdfba7b56d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 16:47:18 -0400 Subject: [PATCH 04/39] fix(session): harden shell cancellation (#24553) --- packages/opencode/src/effect/runner.ts | 50 ++-- packages/opencode/src/session/prompt.ts | 265 +++++++++--------- packages/opencode/src/session/run-state.ts | 6 +- packages/opencode/test/effect/runner.test.ts | 16 ++ packages/opencode/test/session/prompt.test.ts | 4 + 5 files changed, 190 insertions(+), 151 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 0e923b1194d0..1e7d4c2966c7 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,10 +1,10 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Latch, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { readonly state: State readonly busy: boolean readonly ensureRunning: (work: Effect.Effect) => Effect.Effect - readonly startShell: (work: Effect.Effect) => Effect.Effect + readonly startShell: (work: Effect.Effect, ready?: Latch.Latch) => Effect.Effect readonly cancel: Effect.Effect } @@ -18,6 +18,8 @@ interface RunHandle { interface ShellHandle { id: number + cancelled: Deferred.Deferred + ready?: Latch.Latch fiber: Fiber.Fiber } @@ -59,6 +61,9 @@ export const make = ( ? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid) : Deferred.done(done, exit).pipe(Effect.asVoid) + const awaitDone = (done: Deferred.Deferred) => + Deferred.await(done).pipe(Effect.catchTag("RunnerCancelled", (e) => onInterrupt ?? Effect.die(e))) + const idleIfCurrent = () => SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten) @@ -89,7 +94,9 @@ export const make = ( SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { - if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const + if (st._tag === "Shell" && st.shell.id === id) { + return [idle, { _tag: "Idle" }] as const + } if (st._tag === "ShellThenRun" && st.shell.id === id) { const run = yield* startRun(st.run.work, st.run.done) return [Effect.void, { _tag: "Running", run }] as const @@ -98,7 +105,12 @@ export const make = ( }), ).pipe(Effect.flatten) - const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) + const stopShell = (shell: ShellHandle) => + Effect.gen(function* () { + if (shell.ready) yield* shell.ready.await.pipe(Effect.exit, Effect.asVoid) + yield* Deferred.succeed(shell.cancelled, undefined).pipe(Effect.asVoid) + yield* Fiber.interrupt(shell.fiber) + }) const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect( @@ -107,30 +119,25 @@ export const make = ( switch (st._tag) { case "Running": case "ShellThenRun": - return [Deferred.await(st.run.done), st] as const + return [awaitDone(st.run.done), st] as const case "Shell": { const run = { id: next(), done: yield* Deferred.make(), work, } satisfies PendingHandle - return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const + return [awaitDone(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const } case "Idle": { const done = yield* Deferred.make() const run = yield* startRun(work, done) - return [Deferred.await(done), { _tag: "Running", run }] as const + return [awaitDone(done), { _tag: "Running", run }] as const } } }), - ).pipe( - Effect.flatten, - Effect.catch( - (e): Effect.Effect => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)), - ), - ) - - const startShell = (work: Effect.Effect) => + ).pipe(Effect.flatten) + + const startShell = (work: Effect.Effect, ready?: Latch.Latch) => SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { @@ -145,13 +152,20 @@ export const make = ( } yield* busy const id = next() + const cancelled = yield* Deferred.make() const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber } satisfies ShellHandle + const shell = { id, cancelled, ready, fiber } satisfies ShellHandle return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt + if ( + Cause.hasInterruptsOnly(exit.cause) || + ((yield* Deferred.isDone(cancelled)) && Cause.hasInterrupts(exit.cause) && !Cause.hasDies(exit.cause)) + ) { + if (onInterrupt) return yield* onInterrupt + return yield* Effect.die(new Cancelled()) + } return yield* Effect.failCause(exit.cause) }), { _tag: "Shell", shell }, @@ -183,8 +197,8 @@ export const make = ( case "ShellThenRun": return [ Effect.gen(function* () { - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) yield* stopShell(st.shell) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) yield* idleIfCurrent() }), { _tag: "Idle" } as const, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f7306280fef6..4c259e4aef5a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,7 +45,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Effect, Exit, Layer, Option, Scope, Context, Schema } from "effect" +import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema } from "effect" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -720,143 +720,145 @@ NOTE: At any point in time through this workflow you should feel free to ask the } satisfies MessageV2.TextPart) }) - const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) { - const ctx = yield* InstanceState.context - const run = yield* runner() - const session = yield* sessions.get(input.sessionID) - if (session.revert) { - yield* revert.cleanup(session) - } - const agent = yield* agents.get(input.agent) - if (!agent) { - const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) - throw error - } - const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) - const userMsg: MessageV2.User = { - id: input.messageID ?? MessageID.ascending(), - sessionID: input.sessionID, - time: { created: Date.now() }, - role: "user", - agent: input.agent, - model: { providerID: model.providerID, modelID: model.modelID }, - } - yield* sessions.updateMessage(userMsg) - const userPart: MessageV2.Part = { - type: "text", - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - text: "The following tool was executed by the user", - synthetic: true, - } - yield* sessions.updatePart(userPart) - - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - sessionID: input.sessionID, - parentID: userMsg.id, - mode: input.agent, - agent: input.agent, - cost: 0, - path: { cwd: ctx.directory, root: ctx.worktree }, - time: { created: Date.now() }, - role: "assistant", - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: model.modelID, - providerID: model.providerID, - } - yield* sessions.updateMessage(msg) - const part: MessageV2.ToolPart = { - type: "tool", - id: PartID.ascending(), - messageID: msg.id, - sessionID: input.sessionID, - tool: "bash", - callID: ulid(), - state: { - status: "running", - time: { start: Date.now() }, - input: { command: input.command }, - }, - } - yield* sessions.updatePart(part) - - const cfg = yield* config.get() - const sh = Shell.preferred(cfg.shell) - const cwd = ctx.directory - const args = Shell.args(sh, input.command, cwd) - const shellEnv = yield* plugin.trigger( - "shell.env", - { cwd, sessionID: input.sessionID, callID: part.callID }, - { env: {} }, - ) - - const cmd = ChildProcess.make(sh, args, { - cwd, - extendEnv: true, - env: { ...shellEnv.env, TERM: "dumb" }, - stdin: "ignore", - forceKillAfter: "3 seconds", - }) - - let output = "" - let aborted = false - const finish = Effect.uninterruptible( + const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, ready?: Latch.Latch) { + return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - if (aborted) { - output += "\n\n" + ["", "User aborted the command", ""].join("\n") - } - if (!msg.time.completed) { - msg.time.completed = Date.now() + const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void + const { msg, part, cwd } = yield* Effect.gen(function* () { + const ctx = yield* InstanceState.context + const session = yield* sessions.get(input.sessionID) + if (session.revert) { + yield* revert.cleanup(session) + } + const agent = yield* agents.get(input.agent) + if (!agent) { + const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) + yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + throw error + } + const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) + const userMsg: MessageV2.User = { + id: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + time: { created: Date.now() }, + role: "user", + agent: input.agent, + model: { providerID: model.providerID, modelID: model.modelID }, + } + yield* sessions.updateMessage(userMsg) + const userPart: MessageV2.Part = { + type: "text", + id: PartID.ascending(), + messageID: userMsg.id, + sessionID: input.sessionID, + text: "The following tool was executed by the user", + synthetic: true, + } + yield* sessions.updatePart(userPart) + + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + sessionID: input.sessionID, + parentID: userMsg.id, + mode: input.agent, + agent: input.agent, + cost: 0, + path: { cwd: ctx.directory, root: ctx.worktree }, + time: { created: Date.now() }, + role: "assistant", + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: model.modelID, + providerID: model.providerID, + } yield* sessions.updateMessage(msg) - } - if (part.state.status === "running") { - part.state = { - status: "completed", - time: { ...part.state.time, end: Date.now() }, - input: part.state.input, - title: "", - metadata: { output, description: "" }, - output, + const part: MessageV2.ToolPart = { + type: "tool", + id: PartID.ascending(), + messageID: msg.id, + sessionID: input.sessionID, + tool: "bash", + callID: ulid(), + state: { + status: "running", + time: { start: Date.now() }, + input: { command: input.command }, + }, } yield* sessions.updatePart(part) - } - }), - ) + return { msg, part, cwd: ctx.directory } + }).pipe(Effect.ensuring(markReady)) - const exit = yield* Effect.gen(function* () { - const handle = yield* spawner.spawn(cmd) - yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) => - Effect.sync(() => { - output += chunk - if (part.state.status === "running") { - part.state.metadata = { output, description: "" } - void run.fork(sessions.updatePart(part)) - } - }), - ) - yield* handle.exitCode - }).pipe( - Effect.scoped, - Effect.onInterrupt(() => - Effect.sync(() => { + const cfg = yield* config.get() + const sh = Shell.preferred(cfg.shell) + const args = Shell.args(sh, input.command, cwd) + let output = "" + let aborted = false + + const finish = Effect.uninterruptible( + Effect.gen(function* () { + if (aborted) { + output += "\n\n" + ["", "User aborted the command", ""].join("\n") + } + if (!msg.time.completed) { + msg.time.completed = Date.now() + yield* sessions.updateMessage(msg) + } + if (part.state.status === "running") { + part.state = { + status: "completed", + time: { ...part.state.time, end: Date.now() }, + input: part.state.input, + title: "", + metadata: { output, description: "" }, + output, + } + yield* sessions.updatePart(part) + } + }), + ) + + const exit = yield* restore( + Effect.gen(function* () { + const shellEnv = yield* plugin.trigger( + "shell.env", + { cwd, sessionID: input.sessionID, callID: part.callID }, + { env: {} }, + ) + const cmd = ChildProcess.make(sh, args, { + cwd, + extendEnv: true, + env: { ...shellEnv.env, TERM: "dumb" }, + stdin: "ignore", + forceKillAfter: "3 seconds", + }) + const handle = yield* spawner.spawn(cmd) + yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) => + Effect.gen(function* () { + output += chunk + if (part.state.status === "running") { + part.state.metadata = { output, description: "" } + yield* sessions.updatePart(part) + } + }), + ) + yield* handle.exitCode + }).pipe(Effect.scoped, Effect.orDie), + ).pipe(Effect.exit) + + if (Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) && !Cause.hasDies(exit.cause)) { aborted = true - }), - ), - Effect.orDie, - Effect.ensuring(finish), - Effect.exit, - ) + } + yield* finish - if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) { - return yield* Effect.failCause(exit.cause) - } + if (Exit.isFailure(exit) && !aborted && !Cause.hasInterruptsOnly(exit.cause)) { + return yield* Effect.failCause(exit.cause) + } - return { info: msg, parts: [part] } + return { info: msg, parts: [part] } + }), + ) }) const getModel = Effect.fn("SessionPrompt.getModel")(function* ( @@ -1507,7 +1509,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( function* (input: ShellInput) { - return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input)) + const ready = yield* Latch.make() + return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready) }, ) diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 4b210d63d717..9d4986f17436 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -1,6 +1,6 @@ import { InstanceState } from "@/effect/instance-state" import { Runner } from "@/effect/runner" -import { Effect, Layer, Scope, Context } from "effect" +import { Effect, Latch, Layer, Scope, Context } from "effect" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID } from "./schema" @@ -18,6 +18,7 @@ export interface Interface { sessionID: SessionID, onInterrupt: Effect.Effect, work: Effect.Effect, + ready?: Latch.Latch, ) => Effect.Effect } @@ -95,8 +96,9 @@ export const layer = Layer.effect( sessionID: SessionID, onInterrupt: Effect.Effect, work: Effect.Effect, + ready?: Latch.Latch, ) { - return yield* (yield* runner(sessionID, onInterrupt)).startShell(work) + return yield* (yield* runner(sessionID, onInterrupt)).startShell(work, ready) }) return Service.of({ assertNotBusy, cancel, ensureRunning, startShell }) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 4b0fbc1b51fa..ee99050a8c83 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -334,6 +334,22 @@ describe("Runner", () => { }), ) + it.live( + "cancel does not mask shell defects", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s, { onInterrupt: Effect.succeed("interrupted") }) + + const sh = yield* runner + .startShell(Effect.never.pipe(Effect.ensuring(Effect.die("boom")), Effect.as("ignored"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* runner.cancel + expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true) + }), + ) + // --- shell→run handoff --- it.live( diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 422c1400c9fc..53305694018c 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1470,6 +1470,10 @@ unix( const exit = yield* Fiber.await(loop) expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + const tool = completedTool(exit.value.parts) + expect(tool?.state.output).toContain("User aborted the command") + } yield* Fiber.await(sh) }), From acd15dcc8ab5a01cf8c0f2ef3e21c71e2450a9f2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 16:51:24 -0400 Subject: [PATCH 05/39] test(httpapi): cover full OpenAPI route inventory (#24667) --- .../server/routes/instance/httpapi/control.ts | 72 +++++++++++++ .../server/routes/instance/httpapi/event.ts | 20 +++- .../server/routes/instance/httpapi/global.ts | 102 ++++++++++++++++++ .../src/server/routes/instance/httpapi/pty.ts | 19 ++++ .../server/routes/instance/httpapi/public.ts | 45 ++++++++ .../test/server/httpapi-bridge.test.ts | 19 +++- 6 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/control.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/global.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/public.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/control.ts new file mode 100644 index 000000000000..14cbdf7c4539 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/control.ts @@ -0,0 +1,72 @@ +import { Auth } from "@/auth" +import { ProviderID } from "@/provider/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const AuthParams = Schema.Struct({ + providerID: ProviderID, +}) + +const LogQuery = Schema.Struct({ + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +}) + +const LogInput = Schema.Struct({ + service: Schema.String.annotate({ description: "Service name for the log entry" }), + level: Schema.Union([ + Schema.Literal("debug"), + Schema.Literal("info"), + Schema.Literal("error"), + Schema.Literal("warn"), + ]).annotate({ description: "Log level" }), + message: Schema.String.annotate({ description: "Log message" }), + extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ + description: "Additional metadata for the log entry", + }), +}).annotate({ identifier: "AppLogInput" }) + +export const ControlPaths = { + auth: "/auth/:providerID", + log: "/log", +} as const + +export const ControlApi = HttpApi.make("control") + .add( + HttpApiGroup.make("control") + .add( + HttpApiEndpoint.put("authSet", ControlPaths.auth, { + params: AuthParams, + payload: Auth.Info, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "auth.set", + summary: "Set auth credentials", + description: "Set authentication credentials", + }), + ), + HttpApiEndpoint.delete("authRemove", ControlPaths.auth, { + params: AuthParams, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "auth.remove", + summary: "Remove auth credentials", + description: "Remove authentication credentials", + }), + ), + HttpApiEndpoint.post("log", ControlPaths.log, { + query: LogQuery, + payload: LogInput, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "app.log", + summary: "Write log", + description: "Write a log entry to the server logs with specified level and metadata.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 78113e976d83..3194210cee68 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -1,8 +1,9 @@ import { Bus } from "@/bus" import * as Log from "@opencode-ai/core/util/log" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" const log = Log.create({ service: "server" }) @@ -10,6 +11,23 @@ export const EventPaths = { event: "/event", } as const +export const EventApi = HttpApi.make("event") + .add( + HttpApiGroup.make("event") + .add( + HttpApiEndpoint.get("subscribe", EventPaths.event, { + success: Schema.Unknown, + }).annotateMerge( + OpenApi.annotations({ + identifier: "event.subscribe", + summary: "Subscribe to events", + description: "Get events", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })), + ) + function eventData(data: unknown) { return `data: ${JSON.stringify(data)}\n\n` } diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts new file mode 100644 index 000000000000..44789b12fbd6 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/global.ts @@ -0,0 +1,102 @@ +import { Config } from "@/config/config" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const GlobalHealth = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, +}).annotate({ identifier: "GlobalHealth" }) + +const GlobalEvent = Schema.Struct({ + directory: Schema.String, + project: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + payload: Schema.Unknown, +}).annotate({ identifier: "GlobalEvent" }) + +const GlobalUpgradeInput = Schema.Struct({ + target: Schema.optional(Schema.String), +}).annotate({ identifier: "GlobalUpgradeInput" }) + +const GlobalUpgradeResult = Schema.Union([ + Schema.Struct({ + success: Schema.Literal(true), + version: Schema.String, + }), + Schema.Struct({ + success: Schema.Literal(false), + error: Schema.String, + }), +]).annotate({ identifier: "GlobalUpgradeResult" }) + +export const GlobalPaths = { + health: "/global/health", + event: "/global/event", + config: "/global/config", + dispose: "/global/dispose", + upgrade: "/global/upgrade", +} as const + +export const GlobalApi = HttpApi.make("global") + .add( + HttpApiGroup.make("global") + .add( + HttpApiEndpoint.get("health", GlobalPaths.health, { + success: GlobalHealth, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.health", + summary: "Get health", + description: "Get health information about the OpenCode server.", + }), + ), + HttpApiEndpoint.get("event", GlobalPaths.event, { + success: GlobalEvent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.event", + summary: "Get global events", + description: "Subscribe to global events from the OpenCode system using server-sent events.", + }), + ), + HttpApiEndpoint.get("configGet", GlobalPaths.config, { + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.get", + summary: "Get global configuration", + description: "Retrieve the current global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { + payload: Config.Info, + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.update", + summary: "Update global configuration", + description: "Update global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.dispose", + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + }), + ), + HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { + payload: GlobalUpgradeInput, + success: GlobalUpgradeResult, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.upgrade", + summary: "Upgrade opencode", + description: "Upgrade opencode to the specified version or latest if not specified.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts index 4e46f30df72b..21a2dec5ce6c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/pty.ts @@ -113,6 +113,25 @@ export const PtyApi = HttpApi.make("pty") }), ) +export const PtyConnectApi = HttpApi.make("pty-connect") + .add( + HttpApiGroup.make("pty-connect") + .add( + HttpApiEndpoint.get("connect", PtyPaths.connect, { + params: Params, + query: CursorQuery, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connect", + summary: "Connect to PTY session", + description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), + ) + export const ptyHandlers = Layer.unwrap( Effect.gen(function* () { const pty = yield* Pty.Service diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts new file mode 100644 index 000000000000..1a7f675b3f94 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -0,0 +1,45 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { ConfigApi } from "./config" +import { ControlApi } from "./control" +import { EventApi } from "./event" +import { ExperimentalApi } from "./experimental" +import { FileApi } from "./file" +import { GlobalApi } from "./global" +import { InstanceApi } from "./instance" +import { McpApi } from "./mcp" +import { PermissionApi } from "./permission" +import { ProjectApi } from "./project" +import { ProviderApi } from "./provider" +import { PtyApi, PtyConnectApi } from "./pty" +import { QuestionApi } from "./question" +import { SessionApi } from "./session" +import { SyncApi } from "./sync" +import { TuiApi } from "./tui" +import { WorkspaceApi } from "./workspace" + +export const PublicApi = HttpApi.make("opencode") + .addHttpApi(ControlApi) + .addHttpApi(GlobalApi) + .addHttpApi(EventApi) + .addHttpApi(ConfigApi) + .addHttpApi(ExperimentalApi) + .addHttpApi(FileApi) + .addHttpApi(InstanceApi) + .addHttpApi(McpApi) + .addHttpApi(PermissionApi) + .addHttpApi(ProjectApi) + .addHttpApi(ProviderApi) + .addHttpApi(PtyApi) + .addHttpApi(PtyConnectApi) + .addHttpApi(QuestionApi) + .addHttpApi(SessionApi) + .addHttpApi(SyncApi) + .addHttpApi(TuiApi) + .addHttpApi(WorkspaceApi) + .annotateMerge( + OpenApi.annotations({ + title: "opencode", + version: "1.0.0", + description: "opencode api", + }), + ) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 8f9170d6600b..d185dee3b290 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -19,8 +19,10 @@ import { SessionApi } from "../../src/server/routes/instance/httpapi/session" import { SyncApi } from "../../src/server/routes/instance/httpapi/sync" import { TuiApi } from "../../src/server/routes/instance/httpapi/tui" import { WorkspaceApi } from "../../src/server/routes/instance/httpapi/workspace" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" -import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi" +import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" @@ -33,6 +35,7 @@ const original = { } const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket +const methods = ["get", "post", "put", "delete", "patch"] as const function app(input?: { password?: string; username?: string }) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true @@ -75,6 +78,12 @@ function reflectedHttpApiRoutes() { return [...new Set(routes)] } +function openApiRouteKeys(spec: { paths: Record>> }) { + return Object.entries(spec.paths) + .flatMap(([path, item]) => methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`)) + .sort() +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -129,6 +138,14 @@ describe("HttpApi Hono bridge", () => { expect([...bridgeRoutes].filter((route) => !httpApiRoutes.includes(route)).sort()).toEqual([]) }) + test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { + const honoRoutes = openApiRouteKeys(await Server.openapi()) + const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi)) + + expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) + expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([]) + }) + test("allows requests when auth is disabled", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(`${tmp.path}/hello.txt`, "hello") From dfc0075f90e5dae6f17beb5c337a0b09c3216908 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 27 Apr 2026 20:52:42 +0000 Subject: [PATCH 06/39] chore: generate --- .../server/routes/instance/httpapi/control.ts | 77 ++++++----- .../server/routes/instance/httpapi/event.ts | 31 +++-- .../server/routes/instance/httpapi/global.ts | 125 +++++++++--------- .../src/server/routes/instance/httpapi/pty.ts | 36 ++--- .../test/server/httpapi-bridge.test.ts | 4 +- 5 files changed, 136 insertions(+), 137 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/control.ts index 14cbdf7c4539..f850f76e7e8a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/control.ts @@ -31,42 +31,41 @@ export const ControlPaths = { log: "/log", } as const -export const ControlApi = HttpApi.make("control") - .add( - HttpApiGroup.make("control") - .add( - HttpApiEndpoint.put("authSet", ControlPaths.auth, { - params: AuthParams, - payload: Auth.Info, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "auth.set", - summary: "Set auth credentials", - description: "Set authentication credentials", - }), - ), - HttpApiEndpoint.delete("authRemove", ControlPaths.auth, { - params: AuthParams, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "auth.remove", - summary: "Remove auth credentials", - description: "Remove authentication credentials", - }), - ), - HttpApiEndpoint.post("log", ControlPaths.log, { - query: LogQuery, - payload: LogInput, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "app.log", - summary: "Write log", - description: "Write a log entry to the server logs with specified level and metadata.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), - ) +export const ControlApi = HttpApi.make("control").add( + HttpApiGroup.make("control") + .add( + HttpApiEndpoint.put("authSet", ControlPaths.auth, { + params: AuthParams, + payload: Auth.Info, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "auth.set", + summary: "Set auth credentials", + description: "Set authentication credentials", + }), + ), + HttpApiEndpoint.delete("authRemove", ControlPaths.auth, { + params: AuthParams, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "auth.remove", + summary: "Remove auth credentials", + description: "Remove authentication credentials", + }), + ), + HttpApiEndpoint.post("log", ControlPaths.log, { + query: LogQuery, + payload: LogInput, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "app.log", + summary: "Write log", + description: "Write a log entry to the server logs with specified level and metadata.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 3194210cee68..1d548e0bafd4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -11,22 +11,21 @@ export const EventPaths = { event: "/event", } as const -export const EventApi = HttpApi.make("event") - .add( - HttpApiGroup.make("event") - .add( - HttpApiEndpoint.get("subscribe", EventPaths.event, { - success: Schema.Unknown, - }).annotateMerge( - OpenApi.annotations({ - identifier: "event.subscribe", - summary: "Subscribe to events", - description: "Get events", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })), - ) +export const EventApi = HttpApi.make("event").add( + HttpApiGroup.make("event") + .add( + HttpApiEndpoint.get("subscribe", EventPaths.event, { + success: Schema.Unknown, + }).annotateMerge( + OpenApi.annotations({ + identifier: "event.subscribe", + summary: "Subscribe to events", + description: "Get events", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })), +) function eventData(data: unknown) { return `data: ${JSON.stringify(data)}\n\n` diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts index 44789b12fbd6..215c19ef713d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/global.ts @@ -37,66 +37,65 @@ export const GlobalPaths = { upgrade: "/global/upgrade", } as const -export const GlobalApi = HttpApi.make("global") - .add( - HttpApiGroup.make("global") - .add( - HttpApiEndpoint.get("health", GlobalPaths.health, { - success: GlobalHealth, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.health", - summary: "Get health", - description: "Get health information about the OpenCode server.", - }), - ), - HttpApiEndpoint.get("event", GlobalPaths.event, { - success: GlobalEvent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.event", - summary: "Get global events", - description: "Subscribe to global events from the OpenCode system using server-sent events.", - }), - ), - HttpApiEndpoint.get("configGet", GlobalPaths.config, { - success: Config.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.config.get", - summary: "Get global configuration", - description: "Retrieve the current global OpenCode configuration settings and preferences.", - }), - ), - HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { - payload: Config.Info, - success: Config.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.config.update", - summary: "Update global configuration", - description: "Update global OpenCode configuration settings and preferences.", - }), - ), - HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.dispose", - summary: "Dispose instance", - description: "Clean up and dispose all OpenCode instances, releasing all resources.", - }), - ), - HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { - payload: GlobalUpgradeInput, - success: GlobalUpgradeResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.upgrade", - summary: "Upgrade opencode", - description: "Upgrade opencode to the specified version or latest if not specified.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), - ) +export const GlobalApi = HttpApi.make("global").add( + HttpApiGroup.make("global") + .add( + HttpApiEndpoint.get("health", GlobalPaths.health, { + success: GlobalHealth, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.health", + summary: "Get health", + description: "Get health information about the OpenCode server.", + }), + ), + HttpApiEndpoint.get("event", GlobalPaths.event, { + success: GlobalEvent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.event", + summary: "Get global events", + description: "Subscribe to global events from the OpenCode system using server-sent events.", + }), + ), + HttpApiEndpoint.get("configGet", GlobalPaths.config, { + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.get", + summary: "Get global configuration", + description: "Retrieve the current global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { + payload: Config.Info, + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.update", + summary: "Update global configuration", + description: "Update global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.dispose", + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + }), + ), + HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { + payload: GlobalUpgradeInput, + success: GlobalUpgradeResult, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.upgrade", + summary: "Upgrade opencode", + description: "Upgrade opencode to the specified version or latest if not specified.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts index 21a2dec5ce6c..f1ac093998a7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/pty.ts @@ -113,24 +113,24 @@ export const PtyApi = HttpApi.make("pty") }), ) -export const PtyConnectApi = HttpApi.make("pty-connect") - .add( - HttpApiGroup.make("pty-connect") - .add( - HttpApiEndpoint.get("connect", PtyPaths.connect, { - params: Params, - query: CursorQuery, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.connect", - summary: "Connect to PTY session", - description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), - ) +export const PtyConnectApi = HttpApi.make("pty-connect").add( + HttpApiGroup.make("pty-connect") + .add( + HttpApiEndpoint.get("connect", PtyPaths.connect, { + params: Params, + query: CursorQuery, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connect", + summary: "Connect to PTY session", + description: + "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), +) export const ptyHandlers = Layer.unwrap( Effect.gen(function* () { diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index d185dee3b290..c0482293b1f9 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -80,7 +80,9 @@ function reflectedHttpApiRoutes() { function openApiRouteKeys(spec: { paths: Record>> }) { return Object.entries(spec.paths) - .flatMap(([path, item]) => methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`)) + .flatMap(([path, item]) => + methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), + ) .sort() } From 576efed1969e0d73b232a63a9cc8c18a6add4f9d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 17:38:28 -0400 Subject: [PATCH 07/39] fix(httpapi): preserve optional session fields (#24671) --- .../server/routes/instance/httpapi/session.ts | 36 +- .../test/server/httpapi-session.test.ts | 462 +++++++++--------- 2 files changed, 264 insertions(+), 234 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts index dccfb3ecbdf1..142246a84a39 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts @@ -3,6 +3,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { Command } from "@/command" +import { WorkspaceID } from "@/control-plane/schema" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { Instance } from "@/project/instance" @@ -21,7 +22,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import { Effect, Layer, Schema, Struct } from "effect" +import { Effect, Layer, Option, Schema, SchemaGetter, Struct } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { @@ -44,6 +45,19 @@ const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) +const omitUndefined = (schema: S) => + Schema.optionalKey(schema).pipe( + Schema.decodeTo(Schema.optional(schema), { + decode: SchemaGetter.passthrough({ strict: false }), + encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), + }), + ) +const SessionInfoResponse = Session.Info.mapFields( + Struct.evolve({ + workspaceID: () => omitUndefined(WorkspaceID), + parentID: () => omitUndefined(SessionID), + }), +) const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) const MessagesQuery = Schema.Struct({ limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), @@ -123,7 +137,7 @@ export const SessionApi = HttpApi.make("session") .add( HttpApiEndpoint.get("list", SessionPaths.list, { query: ListQuery, - success: Schema.Array(Session.Info), + success: Schema.Array(SessionInfoResponse), }).annotateMerge( OpenApi.annotations({ identifier: "session.list", @@ -142,7 +156,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, - success: Session.Info, + success: SessionInfoResponse, }).annotateMerge( OpenApi.annotations({ identifier: "session.get", @@ -152,7 +166,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("children", SessionPaths.children, { params: { sessionID: SessionID }, - success: Schema.Array(Session.Info), + success: Schema.Array(SessionInfoResponse), }).annotateMerge( OpenApi.annotations({ identifier: "session.children", @@ -204,7 +218,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("create", SessionPaths.create, { payload: [HttpApiSchema.NoContent, Session.CreateInput], - success: Session.Info, + success: SessionInfoResponse, }).annotateMerge( OpenApi.annotations({ identifier: "session.create", @@ -225,7 +239,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.patch("update", SessionPaths.update, { params: { sessionID: SessionID }, payload: UpdatePayload, - success: Session.Info, + success: SessionInfoResponse, }).annotateMerge( OpenApi.annotations({ identifier: "session.update", @@ -236,7 +250,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, payload: ForkPayload, - success: Session.Info, + success: SessionInfoResponse, }).annotateMerge( OpenApi.annotations({ identifier: "session.fork", @@ -268,7 +282,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, - success: Session.Info, + success: SessionInfoResponse, }).annotateMerge( OpenApi.annotations({ identifier: "session.share", @@ -278,7 +292,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, - success: Session.Info, + success: SessionInfoResponse, }).annotateMerge( OpenApi.annotations({ identifier: "session.unshare", @@ -345,7 +359,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("revert", SessionPaths.revert, { params: { sessionID: SessionID }, payload: RevertPayload, - success: Session.Info, + success: SessionInfoResponse, }).annotateMerge( OpenApi.annotations({ identifier: "session.revert", @@ -356,7 +370,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { params: { sessionID: SessionID }, - success: Session.Info, + success: SessionInfoResponse, }).annotateMerge( OpenApi.annotations({ identifier: "session.unrevert", diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 264e66022148..aa7e33a034ec 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" import type { UpgradeWebSocket } from "hono/ws" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" @@ -13,6 +13,7 @@ import { MessageV2 } from "../../src/session/message-v2" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" void Log.init({ print: false }) @@ -32,44 +33,70 @@ function pathFor(path: string, params: Record) { return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) } -async function createSession(directory: string, input?: Session.CreateInput) { - return Instance.provide({ - directory, - fn: async () => runSession(Session.Service.use((svc) => svc.create(input))), - }) +function createSession(directory: string, input?: Session.CreateInput) { + return Effect.promise( + async () => + await Instance.provide({ + directory, + fn: () => runSession(Session.Service.use((svc) => svc.create(input))), + }), + ) +} + +function createTextMessage(directory: string, sessionID: SessionID, text: string) { + return Effect.promise( + async () => + await Instance.provide({ + directory, + fn: () => + runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + const info = yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const part = yield* svc.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text, + }) + return { info, part } + }), + ), + }), + ) +} + +function request(path: string, init?: RequestInit) { + return Effect.promise(async () => app().request(path, init)) } -async function createTextMessage(directory: string, sessionID: SessionID, text: string) { - return Instance.provide({ - directory, - fn: async () => - runSession( - Effect.gen(function* () { - const svc = yield* Session.Service - const info = yield* svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - const part = yield* svc.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: info.id, - type: "text", - text, - }) - return { info, part } - }), - ), +function json(response: Response) { + return Effect.promise(async () => { + if (response.status !== 200) throw new Error(await response.text()) + return (await response.json()) as T }) } -async function json(response: Response) { - if (response.status !== 200) throw new Error(await response.text()) - return (await response.json()) as T +function requestJson(path: string, init?: RequestInit) { + return request(path, init).pipe(Effect.flatMap(json)) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) } afterEach(async () => { @@ -79,210 +106,199 @@ afterEach(async () => { }) describe("session HttpApi", () => { - test("serves read routes through Hono bridge", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - const parent = await createSession(tmp.path, { title: "parent" }) - const child = await createSession(tmp.path, { title: "child", parentID: parent.id }) - const message = await createTextMessage(tmp.path, parent.id, "hello") - await createTextMessage(tmp.path, parent.id, "world") - - expect( - (await json(await app().request(`${SessionPaths.list}?roots=true`, { headers }))).map( - (item) => item.id, - ), - ).toContain(parent.id) - - expect(await json>(await app().request(SessionPaths.status, { headers }))).toEqual({}) - - expect( - await json(await app().request(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers })), - ).toMatchObject({ id: parent.id, title: "parent" }) - - expect( - ( - await json( - await app().request(pathFor(SessionPaths.children, { sessionID: parent.id }), { headers }), - ) - ).map((item) => item.id), - ).toEqual([child.id]) - - expect( - await json(await app().request(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers })), - ).toEqual([]) - - expect( - await json(await app().request(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers })), - ).toEqual([]) - - const messages = await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, { - headers, - }) - const messagePage = await json(messages) - const nextCursor = messages.headers.get("x-next-cursor") - expect(nextCursor).toBeTruthy() - expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" }) - - expect( - ( - await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, { + it.live( + "serves read routes through Hono bridge", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + const parent = yield* createSession(tmp.path, { title: "parent" }) + const child = yield* createSession(tmp.path, { title: "child", parentID: parent.id }) + const message = yield* createTextMessage(tmp.path, parent.id, "hello") + yield* createTextMessage(tmp.path, parent.id, "world") + + const listed = yield* requestJson(`${SessionPaths.list}?roots=true`, { headers }) + expect(listed.map((item) => item.id)).toContain(parent.id) + expect(Object.hasOwn(listed[0]!, "parentID")).toBe(false) + + expect(yield* requestJson>(SessionPaths.status, { headers })).toEqual({}) + + expect( + yield* requestJson(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers }), + ).toMatchObject({ id: parent.id, title: "parent" }) + + expect( + (yield* requestJson(pathFor(SessionPaths.children, { sessionID: parent.id }), { + headers, + })).map((item) => item.id), + ).toEqual([child.id]) + + expect( + yield* requestJson(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers }), + ).toEqual([]) + + expect( + yield* requestJson(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers }), + ).toEqual([]) + + const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, { headers, }) - ).status, - ).toBe(400) - expect( - ( - await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, { + const messagePage = yield* json(messages) + const nextCursor = messages.headers.get("x-next-cursor") + expect(nextCursor).toBeTruthy() + expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" }) + + expect( + (yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, { + headers, + })).status, + ).toBe(400) + expect( + (yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, { + headers, + })).status, + ).toBe(400) + + expect( + yield* requestJson( + pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }), + { headers }, + ), + ).toMatchObject({ info: { id: message.info.id } }) + }), + ), + ) + + it.live( + "serves lifecycle mutation routes through Hono bridge", + withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + + const createdEmpty = yield* requestJson(SessionPaths.create, { + method: "POST", headers, }) - ).status, - ).toBe(400) + expect(createdEmpty.id).toBeTruthy() - expect( - await json( - await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }), { + const created = yield* requestJson(SessionPaths.create, { + method: "POST", headers, - }), - ), - ).toMatchObject({ info: { id: message.info.id } }) - }) - - test("serves lifecycle mutation routes through Hono bridge", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - - const createdEmpty = await json( - await app().request(SessionPaths.create, { - method: "POST", - headers, - }), - ) - expect(createdEmpty.id).toBeTruthy() - - const created = await json( - await app().request(SessionPaths.create, { - method: "POST", - headers, - body: JSON.stringify({ title: "created" }), - }), - ) - expect(created.title).toBe("created") - - const updated = await json( - await app().request(pathFor(SessionPaths.update, { sessionID: created.id }), { - method: "PATCH", - headers, - body: JSON.stringify({ title: "updated", time: { archived: 1 } }), - }), - ) - expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } }) - - const forked = await json( - await app().request(pathFor(SessionPaths.fork, { sessionID: created.id }), { - method: "POST", - headers, - body: JSON.stringify({}), - }), - ) - expect(forked.id).not.toBe(created.id) - - expect( - await json( - await app().request(pathFor(SessionPaths.abort, { sessionID: created.id }), { method: "POST", headers }), - ), - ).toBe(true) - - expect( - await json( - await app().request(pathFor(SessionPaths.remove, { sessionID: created.id }), { method: "DELETE", headers }), - ), - ).toBe(true) - }) + body: JSON.stringify({ title: "created" }), + }) + expect(created.title).toBe("created") - test("serves message mutation routes through Hono bridge", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const session = await createSession(tmp.path, { title: "messages" }) - const first = await createTextMessage(tmp.path, session.id, "first") - const second = await createTextMessage(tmp.path, session.id, "second") - - const updated = await json( - await app().request( - pathFor(SessionPaths.updatePart, { - sessionID: session.id, - messageID: first.info.id, - partID: first.part.id, - }), - { + const updated = yield* requestJson(pathFor(SessionPaths.update, { sessionID: created.id }), { method: "PATCH", headers, - body: JSON.stringify({ ...first.part, text: "updated" }), - }, - ), - ) - expect(updated).toMatchObject({ id: first.part.id, type: "text", text: "updated" }) - - expect( - await json( - await app().request( - pathFor(SessionPaths.deletePart, { - sessionID: session.id, - messageID: first.info.id, - partID: first.part.id, - }), - { method: "DELETE", headers }, - ), - ), - ).toBe(true) - - expect( - await json( - await app().request(pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID: second.info.id }), { - method: "DELETE", - headers, - }), - ), - ).toBe(true) - }) - - test("serves remaining non-LLM session mutation routes through Hono bridge", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const session = await createSession(tmp.path, { title: "remaining" }) + body: JSON.stringify({ title: "updated", time: { archived: 1 } }), + }) + expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } }) - expect( - await json( - await app().request(pathFor(SessionPaths.revert, { sessionID: session.id }), { - method: "POST", - headers, - body: JSON.stringify({ messageID: MessageID.ascending() }), - }), - ), - ).toMatchObject({ id: session.id }) - - expect( - await json( - await app().request(pathFor(SessionPaths.unrevert, { sessionID: session.id }), { + const forked = yield* requestJson(pathFor(SessionPaths.fork, { sessionID: created.id }), { method: "POST", headers, - }), - ), - ).toMatchObject({ id: session.id }) - - expect( - await json( - await app().request( - pathFor(SessionPaths.permissions, { + body: JSON.stringify({}), + }) + expect(forked.id).not.toBe(created.id) + + expect( + yield* requestJson(pathFor(SessionPaths.abort, { sessionID: created.id }), { + method: "POST", + headers, + }), + ).toBe(true) + + expect( + yield* requestJson(pathFor(SessionPaths.remove, { sessionID: created.id }), { + method: "DELETE", + headers, + }), + ).toBe(true) + }), + ), + ) + + it.live( + "serves message mutation routes through Hono bridge", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + const session = yield* createSession(tmp.path, { title: "messages" }) + const first = yield* createTextMessage(tmp.path, session.id, "first") + const second = yield* createTextMessage(tmp.path, session.id, "second") + + const updated = yield* requestJson( + pathFor(SessionPaths.updatePart, { sessionID: session.id, - permissionID: String(PermissionID.ascending()), + messageID: first.info.id, + partID: first.part.id, }), { - method: "POST", + method: "PATCH", headers, - body: JSON.stringify({ response: "once" }), + body: JSON.stringify({ ...first.part, text: "updated" }), }, - ), - ), - ).toBe(true) - }) + ) + expect(updated).toMatchObject({ id: first.part.id, type: "text", text: "updated" }) + + expect( + yield* requestJson( + pathFor(SessionPaths.deletePart, { + sessionID: session.id, + messageID: first.info.id, + partID: first.part.id, + }), + { method: "DELETE", headers }, + ), + ).toBe(true) + + expect( + yield* requestJson( + pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID: second.info.id }), + { method: "DELETE", headers }, + ), + ).toBe(true) + }), + ), + ) + + it.live( + "serves remaining non-LLM session mutation routes through Hono bridge", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + const session = yield* createSession(tmp.path, { title: "remaining" }) + + expect( + yield* requestJson(pathFor(SessionPaths.revert, { sessionID: session.id }), { + method: "POST", + headers, + body: JSON.stringify({ messageID: MessageID.ascending() }), + }), + ).toMatchObject({ id: session.id }) + + expect( + yield* requestJson(pathFor(SessionPaths.unrevert, { sessionID: session.id }), { + method: "POST", + headers, + }), + ).toMatchObject({ id: session.id }) + + expect( + yield* requestJson( + pathFor(SessionPaths.permissions, { + sessionID: session.id, + permissionID: String(PermissionID.ascending()), + }), + { + method: "POST", + headers, + body: JSON.stringify({ response: "once" }), + }, + ), + ).toBe(true) + }), + ), + ) }) From c4a2353ac3a962d7fe0f4deaa539854345e1c11e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 17:50:09 -0400 Subject: [PATCH 08/39] fix(session): omit undefined optional fields (#24676) --- .../server/routes/instance/httpapi/session.ts | 36 ++++--------- packages/opencode/src/session/session.ts | 28 +++++----- packages/opencode/src/util/schema.ts | 16 +++++- .../test/session/session-schema.test.ts | 53 +++++++++++++++++++ 4 files changed, 93 insertions(+), 40 deletions(-) create mode 100644 packages/opencode/test/session/session-schema.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts index 142246a84a39..dccfb3ecbdf1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts @@ -3,7 +3,6 @@ import { AppRuntime } from "@/effect/app-runtime" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { Command } from "@/command" -import { WorkspaceID } from "@/control-plane/schema" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { Instance } from "@/project/instance" @@ -22,7 +21,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import { Effect, Layer, Option, Schema, SchemaGetter, Struct } from "effect" +import { Effect, Layer, Schema, Struct } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { @@ -45,19 +44,6 @@ const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) -const omitUndefined = (schema: S) => - Schema.optionalKey(schema).pipe( - Schema.decodeTo(Schema.optional(schema), { - decode: SchemaGetter.passthrough({ strict: false }), - encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), - }), - ) -const SessionInfoResponse = Session.Info.mapFields( - Struct.evolve({ - workspaceID: () => omitUndefined(WorkspaceID), - parentID: () => omitUndefined(SessionID), - }), -) const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) const MessagesQuery = Schema.Struct({ limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), @@ -137,7 +123,7 @@ export const SessionApi = HttpApi.make("session") .add( HttpApiEndpoint.get("list", SessionPaths.list, { query: ListQuery, - success: Schema.Array(SessionInfoResponse), + success: Schema.Array(Session.Info), }).annotateMerge( OpenApi.annotations({ identifier: "session.list", @@ -156,7 +142,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, - success: SessionInfoResponse, + success: Session.Info, }).annotateMerge( OpenApi.annotations({ identifier: "session.get", @@ -166,7 +152,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("children", SessionPaths.children, { params: { sessionID: SessionID }, - success: Schema.Array(SessionInfoResponse), + success: Schema.Array(Session.Info), }).annotateMerge( OpenApi.annotations({ identifier: "session.children", @@ -218,7 +204,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("create", SessionPaths.create, { payload: [HttpApiSchema.NoContent, Session.CreateInput], - success: SessionInfoResponse, + success: Session.Info, }).annotateMerge( OpenApi.annotations({ identifier: "session.create", @@ -239,7 +225,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.patch("update", SessionPaths.update, { params: { sessionID: SessionID }, payload: UpdatePayload, - success: SessionInfoResponse, + success: Session.Info, }).annotateMerge( OpenApi.annotations({ identifier: "session.update", @@ -250,7 +236,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, payload: ForkPayload, - success: SessionInfoResponse, + success: Session.Info, }).annotateMerge( OpenApi.annotations({ identifier: "session.fork", @@ -282,7 +268,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, - success: SessionInfoResponse, + success: Session.Info, }).annotateMerge( OpenApi.annotations({ identifier: "session.share", @@ -292,7 +278,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, - success: SessionInfoResponse, + success: Session.Info, }).annotateMerge( OpenApi.annotations({ identifier: "session.unshare", @@ -359,7 +345,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("revert", SessionPaths.revert, { params: { sessionID: SessionID }, payload: RevertPayload, - success: SessionInfoResponse, + success: Session.Info, }).annotateMerge( OpenApi.annotations({ identifier: "session.revert", @@ -370,7 +356,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { params: { sessionID: SessionID }, - success: SessionInfoResponse, + success: Session.Info, }).annotateMerge( OpenApi.annotations({ identifier: "session.unrevert", diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e167908e8349..673347b206ff 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -37,7 +37,7 @@ import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@/util/schema" const log = Log.create({ service: "session" }) @@ -128,7 +128,7 @@ const Summary = Schema.Struct({ additions: Schema.Number, deletions: Schema.Number, files: Schema.Number, - diffs: Schema.optional(Schema.Array(Snapshot.FileDiff)), + diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), }) const Share = Schema.Struct({ @@ -138,31 +138,31 @@ const Share = Schema.Struct({ const Time = Schema.Struct({ created: Schema.Number, updated: Schema.Number, - compacting: Schema.optional(Schema.Number), - archived: Schema.optional(Schema.Number), + compacting: optionalOmitUndefined(Schema.Number), + archived: optionalOmitUndefined(Schema.Number), }) const Revert = Schema.Struct({ messageID: MessageID, - partID: Schema.optional(PartID), - snapshot: Schema.optional(Schema.String), - diff: Schema.optional(Schema.String), + partID: optionalOmitUndefined(PartID), + snapshot: optionalOmitUndefined(Schema.String), + diff: optionalOmitUndefined(Schema.String), }) export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, projectID: ProjectID, - workspaceID: Schema.optional(WorkspaceID), + workspaceID: optionalOmitUndefined(WorkspaceID), directory: Schema.String, - parentID: Schema.optional(SessionID), - summary: Schema.optional(Summary), - share: Schema.optional(Share), + parentID: optionalOmitUndefined(SessionID), + summary: optionalOmitUndefined(Summary), + share: optionalOmitUndefined(Share), title: Schema.String, version: Schema.String, time: Time, - permission: Schema.optional(Permission.Ruleset), - revert: Schema.optional(Revert), + permission: optionalOmitUndefined(Permission.Ruleset), + revert: optionalOmitUndefined(Revert), }) .annotate({ identifier: "Session" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -170,7 +170,7 @@ export type Info = Types.DeepMutable> export const ProjectInfo = Schema.Struct({ id: ProjectID, - name: Schema.optional(Schema.String), + name: optionalOmitUndefined(Schema.String), worktree: Schema.String, }) .annotate({ identifier: "ProjectSummary" }) diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 0c50482bbda6..1daab260fbb1 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -1,4 +1,5 @@ -import { Schema } from "effect" +import { Option, Schema, SchemaGetter } from "effect" +import { zod, ZodOverride } from "./effect-zod" /** * Integer greater than zero. @@ -10,6 +11,19 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) */ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) +/** + * Optional public JSON field that accepts explicit `undefined` internally but + * encodes it as an omitted key, matching `JSON.stringify` legacy responses. + */ +export const optionalOmitUndefined = (schema: S) => + Schema.optionalKey(schema).pipe( + Schema.decodeTo(Schema.optional(schema), { + decode: SchemaGetter.passthrough({ strict: false }), + encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), + }), + Schema.annotate({ [ZodOverride]: zod(schema).optional() }), + ) + /** * Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable` * until `effect:core/x228my` ("Types.DeepMutable widens unknown to `{}`") lands. diff --git a/packages/opencode/test/session/session-schema.test.ts b/packages/opencode/test/session/session-schema.test.ts new file mode 100644 index 000000000000..cefe6e73af12 --- /dev/null +++ b/packages/opencode/test/session/session-schema.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ProjectID } from "../../src/project/schema" +import { SessionID } from "../../src/session/schema" +import { Session } from "../../src/session/session" + +const info = { + id: SessionID.descending(), + slug: "test-session", + projectID: ProjectID.global, + workspaceID: undefined, + directory: "/tmp/opencode", + parentID: undefined, + summary: undefined, + share: undefined, + title: "Test session", + version: "1.0.0", + time: { + created: 1, + updated: 2, + compacting: undefined, + archived: undefined, + }, + permission: undefined, + revert: undefined, +} satisfies Session.Info + +describe("Session schema", () => { + test("encodes undefined optional session fields as omitted keys", () => { + const encoded = Schema.encodeUnknownSync(Session.Info)(info) as Record + + for (const key of ["workspaceID", "parentID", "summary", "share", "permission", "revert"]) { + expect(Object.hasOwn(encoded, key)).toBe(false) + } + expect(Object.hasOwn(encoded.time as Record, "compacting")).toBe(false) + expect(Object.hasOwn(encoded.time as Record, "archived")).toBe(false) + expect(JSON.stringify(encoded)).not.toContain("parentID") + }) + + test("encodes undefined optional global session project fields as omitted keys", () => { + const encoded = Schema.encodeUnknownSync(Session.GlobalInfo)({ + ...info, + project: { + id: ProjectID.global, + name: undefined, + worktree: "/tmp/opencode", + }, + }) as Record + + expect(Object.hasOwn(encoded, "parentID")).toBe(false) + expect(Object.hasOwn(encoded.project as Record, "name")).toBe(false) + }) +}) From ce78a4265d7b01de6e414ab0ac3bbbcdd9ff226d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 18:15:11 -0400 Subject: [PATCH 09/39] fix(session): remove compaction summary dividers (#24677) --- packages/opencode/src/session/compaction.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index d1a3d327ff74..aaee2be2feba 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -37,8 +37,8 @@ const PRUNE_PROTECTED_TOOLS = ["skill"] const DEFAULT_TAIL_TURNS = 2 const MIN_PRESERVE_RECENT_TOKENS = 2_000 const MAX_PRESERVE_RECENT_TOKENS = 8_000 -const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged: ---- +const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside