Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/opencode/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
const path = c.req.path
const method = c.req.method
if (path === "/event" || path === "/global/event") return next()
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
// These POST routes respond with 204 (no body) or stream raw text; gzip is wasted CPU.
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async|command_async)$/.test(path)) return next()
return zipped(c, next)
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export const SummarizePayload = Schema.Struct({
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
export const CommandAsyncPayload = Schema.Struct({
...Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID", "model"]),
model: Schema.optional(SessionPrompt.PromptInput.fields.model),
})
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))
export const PermissionResponsePayload = Schema.Struct({
Expand All @@ -88,6 +92,7 @@ export const SessionPaths = {
prompt: `${root}/:sessionID/message`,
promptAsync: `${root}/:sessionID/prompt_async`,
command: `${root}/:sessionID/command`,
commandAsync: `${root}/:sessionID/command_async`,
shell: `${root}/:sessionID/shell`,
revert: `${root}/:sessionID/revert`,
unrevert: `${root}/:sessionID/unrevert`,
Expand Down Expand Up @@ -329,6 +334,19 @@ export const SessionApi = HttpApi.make("session")
description: "Send a new command to a session for execution by the AI assistant.",
}),
),
HttpApiEndpoint.post("commandAsync", SessionPaths.commandAsync, {
params: { sessionID: SessionID },
payload: CommandAsyncPayload,
success: described(HttpApiSchema.NoContent, "Command accepted"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command_async",
summary: "Send async command",
description:
"Send a new command to a session asynchronously, starting the session if needed and returning immediately.",
}),
),
HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID },
payload: ShellPayload,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/htt
import { InstanceHttpApi } from "../api"
import {
CommandPayload,
CommandAsyncPayload,
DiffQuery,
ForkPayload,
InitPayload,
Expand Down Expand Up @@ -293,6 +294,31 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
})

const commandAsync = Effect.fn("SessionHttpApi.commandAsync")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof CommandAsyncPayload.Type
}) {
yield* promptSvc
.command({
...ctx.payload,
sessionID: ctx.params.sessionID,
model: ctx.payload.model ? `${ctx.payload.model.providerID}/${ctx.payload.model.modelID}` : undefined,
})
.pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logError("command_async failed", { sessionID: ctx.params.sessionID, cause })
yield* bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
})
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
return HttpApiSchema.NoContent.make()
})

const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ShellPayload.Type
Expand Down Expand Up @@ -372,6 +398,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
.handle("prompt", prompt)
.handle("promptAsync", promptAsync)
.handle("command", command)
.handle("commandAsync", commandAsync)
.handle("shell", shell)
.handle("revert", revert)
.handle("unrevert", unrevert)
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/server/routes/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context))
app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context))
app.post(SessionPaths.command, (c) => handler(c.req.raw, context))
app.post(SessionPaths.commandAsync, (c) => handler(c.req.raw, context))
app.post(SessionPaths.shell, (c) => handler(c.req.raw, context))
app.post(SessionPaths.revert, (c) => handler(c.req.raw, context))
app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context))
Expand Down
69 changes: 67 additions & 2 deletions packages/opencode/src/server/routes/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { SessionShare } from "@/share/session"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { Effect } from "effect"
import { Cause, Effect } from "effect"
import { Agent } from "@/agent/agent"
import { Snapshot } from "@/snapshot"
import { Command } from "@/command"
Expand All @@ -23,7 +23,7 @@ import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { zodObject } from "@/util/effect-zod"
import { zod, zodObject } from "@/util/effect-zod"
import { Bus } from "@/bus"
import { NamedError } from "@opencode-ai/core/util/error"
import { jsonRequest, runRequest } from "./trace"
Expand All @@ -40,6 +40,21 @@ function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
return value === true || value === "true"
}

const CommandAsyncBody = zodObject(SessionPrompt.CommandInput)
.omit({ sessionID: true, model: true })
.extend({ model: zod(SessionPrompt.PromptInput.fields.model).optional() })
type CommandAsyncBody = Omit<SessionPrompt.CommandInput, "sessionID" | "model"> & {
model?: SessionPrompt.PromptInput["model"]
}

function commandAsyncInput(body: CommandAsyncBody, sessionID: SessionID): SessionPrompt.CommandInput {
return {
...body,
sessionID,
model: body.model ? `${body.model.providerID}/${body.model.modelID}` : undefined,
}
}

export const SessionRoutes = lazy(() =>
new Hono()
.get(
Expand Down Expand Up @@ -984,6 +999,56 @@ export const SessionRoutes = lazy(() =>
return yield* svc.command({ ...body, sessionID })
}),
)
.post(
"/:sessionID/command_async",
describeRoute({
summary: "Send async command",
description:
"Send a new command to a session asynchronously, starting the session if needed and returning immediately with 204. Subscribe to Session.Event.Error filtered by sessionID before posting to receive background failures.",
operationId: "session.command_async",
responses: {
204: {
description: "Command accepted",
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: SessionID.zod,
}),
),
validator("json", CommandAsyncBody),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") as CommandAsyncBody
// c is only used to build OTel span attributes (method, url, params), all read
// synchronously before this handler returns, so there is no post-204 access to c.
void runRequest(
"SessionRoutes.command_async",
c,
SessionPrompt.Service.use((svc) => svc.command(commandAsyncInput(body, sessionID))).pipe(
Effect.catchCause((cause) => {
const err = Cause.squash(cause)
log.error("command_async failed", { sessionID, error: err })
return Bus.Service.use((bus) =>
bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({
message: err instanceof Error ? err.message : String(err),
}).toObject(),
}),
)
}),
),
).catch((err) => {
log.error("command_async failed", { sessionID, error: err })
})

return c.body(null, 204)
},
)
.post(
"/:sessionID/shell",
describeRoute({
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/test/server/httpapi-sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,27 @@ describe("HttpApi SDK", () => {
),
)

parity("matches generated SDK async command route across backends", (backend) =>
withStandardProject(backend, ({ sdk }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "command async" }))
const sessionID = String(record(session.data).id)
const commandAsync = yield* capture(() =>
sdk.session.commandAsync({
sessionID,
command: "/unknown-sdk-command",
arguments: "",
model: { providerID: "test", modelID: "test-model" },
}),
)

return {
statuses: statuses({ session, commandAsync }),
}
}),
),
)

parity("matches generated SDK prompt streaming through fake LLM across backends", (backend) =>
withFakeLlm(backend, ({ sdk, llm }) =>
Effect.gen(function* () {
Expand Down
94 changes: 94 additions & 0 deletions packages/opencode/test/server/session-command-async.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session as SessionNs } from "@/session/session"
import type { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"

void Log.init({ print: false })

const COMMAND_BODY = {
command: "/unknown-test-command",
arguments: "",
model: {
providerID: "test",
modelID: "test-model",
},
}

function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}

const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
}

afterEach(async () => {
await disposeAllInstances()
})

describe("command_async route", () => {
test("returns 204 immediately", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const app = Server.Default().app

const start = Date.now()
const res = await app.request(`/session/${session.id}/command_async`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(COMMAND_BODY),
})
const elapsed = Date.now() - start

expect(res.status).toBe(204)
// Handler must return before any background processing completes.
expect(elapsed).toBeLessThan(2000)

await svc.remove(session.id)
},
})
})

test("background failure publishes Session.Event.Error", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const errorReceived = waitGlobalBusEventPromise({
predicate: (event) =>
event.payload.type === SessionNs.Event.Error.type && event.payload.properties?.sessionID === session.id,
})

const app = Server.Default().app
const res = await app.request(`/session/${session.id}/command_async`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(COMMAND_BODY),
})

expect(res.status).toBe(204)

// /unknown-test-command is not registered; the background command will fail
// and must surface via Session.Event.Error rather than being swallowed.
expect((await errorReceived).payload.properties?.sessionID).toBe(session.id)

await svc.remove(session.id)
},
})
}, 15_000)
})
Loading
Loading