feat(core): add session form service (#34855)

This commit is contained in:
Aiden Cline 2026-07-02 17:15:18 -05:00 committed by GitHub
commit 7ebd344fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 7862 additions and 5223 deletions

View file

@ -1 +1,4 @@
delete process.env["OTEL_EXPORTER_OTLP_ENDPOINT"]
delete process.env["OTEL_EXPORTER_OTLP_HEADERS"]
await import("../test/server/httpapi-exercise/index")

View file

@ -114,7 +114,7 @@ export async function runNonInteractivePrompt(input: Input) {
const rejectQuestion = async (request: { id: string }) => {
questionRejected = true
await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
await input.client.question.reject({ requestID: request.id }).catch(() => {})
}
const consume = async () => {
@ -127,7 +127,7 @@ export async function runNonInteractivePrompt(input: Input) {
await replyPermission(event.data)
continue
}
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
if (event.type === "question.asked" && submitted && event.data.sessionID === input.sessionID) {
await rejectQuestion(event.data)
continue
}
@ -408,11 +408,11 @@ export async function runNonInteractivePrompt(input: Input) {
const [permissions, questions] = await Promise.all([
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.question.list().catch(() => undefined),
])
await Promise.all([
...(permissions?.data?.data ?? []).map(replyPermission),
...(questions?.data?.data ?? []).map(rejectQuestion),
...(questions?.data ?? []).filter((question) => question.sessionID === input.sessionID).map(rejectQuestion),
])
await completed
} finally {

View file

@ -255,10 +255,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return
}
await ctx.sdk.v2.session.question.reply({
sessionID: state.sessionID,
await ctx.sdk.question.reply({
requestID: next.requestID,
questionV2Reply: { answers: next.answers ?? [] },
answers: next.answers ?? [],
})
},
onQuestionReject: async (next) => {
@ -266,7 +265,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return
}
await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next })
await ctx.sdk.question.reject(next)
},
onCycleVariant: () => {
if (!state.model || state.variants.length === 0) {

View file

@ -3,7 +3,6 @@ import type {
PermissionRequest,
PermissionV2Request,
QuestionRequest,
QuestionV2Request,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantTool,
@ -133,15 +132,6 @@ function permission(request: PermissionV2Request): PermissionRequest {
}
}
function question(request: QuestionV2Request): QuestionRequest {
return {
id: request.id,
sessionID: request.sessionID,
questions: request.questions,
tool: request.tool,
}
}
function sessionID(event: RunV2Event) {
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
}
@ -369,13 +359,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
{ throwOnError: true },
),
input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.question.list({ directory: input.directory }, { throwOnError: true }),
input.sdk.v2.session.active({ throwOnError: true }),
])
const projected = messages.data.data.toReversed()
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.data.data.map(permission)
state.questions = questions.data.data.map(question)
state.questions = questions.data.filter((item) => item.sessionID === input.sessionID)
syncBlockers()
await subagents.hydrate({ messages: projected, active: active.data.data })
const running = input.sessionID in active.data.data
@ -544,12 +534,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
syncBlockers()
return
}
if (event.type === "question.v2.asked") {
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data))
if (event.type === "question.asked") {
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(event.data)
syncBlockers()
return
}
if (event.type === "question.v2.replied" || event.type === "question.v2.rejected") {
if (event.type === "question.replied" || event.type === "question.rejected") {
state.questions = state.questions.filter((item) => item.id !== event.data.requestID)
syncBlockers()
return

View file

@ -25,9 +25,6 @@ import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
import { makeApi } from "@opencode-ai/protocol/api"
import { LocationMiddleware } from "@opencode-ai/server/location"
import { SessionLocationMiddleware } from "@opencode-ai/server/middleware/session-location"
import { GlobalApi } from "./groups/global"
import { Authorization } from "./middleware/authorization"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
@ -45,12 +42,6 @@ const EventSchema = Schema.Union([
InstanceDisposed,
]).annotate({ identifier: "Event" })
export const ServerApi = makeApi({
definitions: EventManifest.Latest.values().toArray(),
locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware,
})
export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
.addHttpApi(ControlPlaneApi)
@ -80,7 +71,6 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(ServerApi)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [
EventSchema,

View file

@ -21,7 +21,6 @@ import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
@ -72,13 +71,11 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { Api } from "@opencode-ai/server/api"
import { PublicApi } from "./public"
import {
authorizationLayer,
authorizationRouterMiddleware,
ptyConnectAuthorizationLayer,
serverAuthorizationLayer,
} from "./middleware/authorization"
import { EventApi } from "./groups/event"
import { PtyConnectApi } from "./groups/pty"
@ -100,12 +97,10 @@ import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { handlers } from "@opencode-ai/server/handlers"
import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services"
import { layer as locationLayer } from "@opencode-ai/server/location"
import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location"
import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer } from "./middleware/instance-context"
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
@ -137,7 +132,6 @@ const cors = (corsOptions?: CorsOptions) =>
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.layer))
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
@ -175,12 +169,6 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
)
const serverRoutes = HttpApiBuilder.layer(Api).pipe(
Layer.provide(handlers),
Layer.provide(PluginPtyEnvironment.layer),
Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
)
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
// processes that never serve it (CLI, scripts) don't pay at module load.
// `HttpServerResponse.jsonUnsafe` runs JSON.stringify eagerly, so caching
@ -279,7 +267,6 @@ export function createRoutes(
eventApiRoutes,
ptyConnectApiRoutes,
instanceRoutes,
serverRoutes,
docRoute,
uiRoute,
).pipe(

View file

@ -112,7 +112,7 @@ function sdk(input: {
}),
)
spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }))
spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] }))
spyOn(client.question, "list").mockImplementation(() => ok([]))
spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {} }))
spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined))
spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))

View file

@ -811,11 +811,6 @@ const scenarios: Scenario[] = [
object(body.location)
array(body.data)
}),
http.protected.get("/api/question/request", "v2.question.request.list").json(200, (body) => {
object(body)
object(body.location)
array(body.data)
}),
http.protected
.post("/api/session/{sessionID}/permission", "v2.session.permission.create")
.seeded((ctx) => ctx.session({ title: "Permission create owner" }))
@ -849,14 +844,6 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(404, object, "status"),
http.protected
.get("/api/session/{sessionID}/question", "v2.session.question.list")
.seeded((ctx) => ctx.session({ title: "Question list owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/question", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, data(array)),
http.protected
.post("/api/session/{sessionID}/permission/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
@ -869,29 +856,6 @@ const scenarios: Scenario[] = [
body: { reply: "once" },
}))
.json(404, object, "status"),
http.protected
.post("/api/session/{sessionID}/question/{requestID}/reply", "v2.session.question.reply")
.seeded((ctx) => ctx.session({ title: "Question reply owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/question/{requestID}/reply", {
sessionID: ctx.state.id,
requestID: "que_httpapi_missing",
}),
headers: ctx.headers(),
body: { answers: [] },
}))
.json(404, object, "status"),
http.protected
.post("/api/session/{sessionID}/question/{requestID}/reject", "v2.session.question.reject")
.seeded((ctx) => ctx.session({ title: "Question reject owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/question/{requestID}/reject", {
sessionID: ctx.state.id,
requestID: "que_httpapi_missing",
}),
headers: ctx.headers(),
}))
.json(404, object, "status"),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, (body) => {
object(body)
array(body.data)
@ -1769,7 +1733,9 @@ const main = Effect.gen(function* () {
const options = parseOptions(Bun.argv.slice(2))
const modules = yield* Effect.promise(() => runtime())
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
const selected = selectedScenarios(options, scenarios)
const selected = selectedScenarios(options, scenarios).filter((scenario) =>
effectRoutes.includes(routeKey(scenario)),
)
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))

View file

@ -137,11 +137,7 @@ describe("PublicApi OpenAPI v2 errors", () => {
test("preserves required request bodies for v2 mutations", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const path of [
"/api/session/{sessionID}/prompt",
"/api/session/{sessionID}/permission/{requestID}/reply",
"/api/session/{sessionID}/question/{requestID}/reply",
]) {
for (const path of ["/api/session/{sessionID}/prompt", "/api/session/{sessionID}/permission/{requestID}/reply"]) {
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
}
})
@ -292,15 +288,6 @@ describe("PublicApi OpenAPI v2 errors", () => {
"QuestionNotFoundError",
)
}
for (const route of [
["post", "/api/session/{sessionID}/question/{requestID}/reply"],
["post", "/api/session/{sessionID}/question/{requestID}/reject"],
] as const) {
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([
"QuestionNotFoundError",
"SessionNotFoundError",
])
}
})
test("documents MCP server not-found errors", () => {