diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 160d258796b7..6de403e04795 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -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) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 1159c8803013..db68e7fd73a8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -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({ @@ -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`, @@ -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, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 56fa7adb15c5..6350e77f1186 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -25,6 +25,7 @@ import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/htt import { InstanceHttpApi } from "../api" import { CommandPayload, + CommandAsyncPayload, DiffQuery, ForkPayload, InitPayload, @@ -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 @@ -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) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 71662dea903d..4e505b16d6bf 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -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)) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index a16a92f927a6..112aa3608e94 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -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" @@ -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" @@ -40,6 +40,21 @@ function queryBoolean(value: z.infer | 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 & { + 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( @@ -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({ diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 6d2df45078b5..a9491a55ba1b 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -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* () { diff --git a/packages/opencode/test/server/session-command-async.test.ts b/packages/opencode/test/server/session-command-async.test.ts new file mode 100644 index 000000000000..f6eae9358d80 --- /dev/null +++ b/packages/opencode/test/server/session-command-async.test.ts @@ -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(fx: Effect.Effect) { + 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) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 803d9ed16e00..a5fa78bac802 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -121,6 +121,8 @@ import type { SessionAbortResponses, SessionChildrenErrors, SessionChildrenResponses, + SessionCommandAsyncErrors, + SessionCommandAsyncResponses, SessionCommandErrors, SessionCommandResponses, SessionCreateErrors, @@ -3633,6 +3635,69 @@ export class Session2 extends HeyApiClient { }) } + /** + * Send async command + * + * Send a new command to a session asynchronously, starting the session if needed and returning immediately. + */ + public commandAsync( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + agent?: string + arguments?: string + command?: string + variant?: string + parts?: Array<{ + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource + }> + model?: { + providerID: string + modelID: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + { in: "body", key: "agent" }, + { in: "body", key: "arguments" }, + { in: "body", key: "command" }, + { in: "body", key: "variant" }, + { in: "body", key: "parts" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post( + { + url: "/session/{sessionID}/command_async", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }, + ) + } + /** * Run shell command * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b58f6cfc2b51..3084778203d8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5725,6 +5725,58 @@ export type SessionCommandResponses = { export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] +export type SessionCommandAsyncData = { + body?: { + messageID?: string + agent?: string + arguments: string + command: string + variant?: string + parts?: Array<{ + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource + }> + model?: { + providerID: string + modelID: string + } + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/command_async" +} + +export type SessionCommandAsyncErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionCommandAsyncError = SessionCommandAsyncErrors[keyof SessionCommandAsyncErrors] + +export type SessionCommandAsyncResponses = { + /** + * Command accepted + */ + 204: void +} + +export type SessionCommandAsyncResponse = SessionCommandAsyncResponses[keyof SessionCommandAsyncResponses] + export type SessionShellData = { body?: { messageID?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 477145f017dc..e5465daf9138 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6069,6 +6069,142 @@ ] } }, + "/session/{sessionID}/command_async": { + "post": { + "tags": ["session"], + "operationId": "session.command_async", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "Command accepted" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Send a new command to a session asynchronously, starting the session if needed and returning immediately.", + "summary": "Send async command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "command": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"], + "additionalProperties": false + } + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + } + }, + "required": ["arguments", "command"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command_async({\n ...\n})" + } + ] + } + }, "/session/{sessionID}/shell": { "post": { "tags": ["session"],