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 3d88db60db..c4def3e742 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,5 +1,5 @@ import * as InstanceState from "@/effect/instance-state" -import { AppRuntime } from "@/effect/app-runtime" +import { EffectBridge } from "@/effect/bridge" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { Command } from "@/command" @@ -53,6 +53,13 @@ const mapNotFound = (self: Effect.Effect) => export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service + const shareSvc = yield* SessionShare.Service + const promptSvc = yield* SessionPrompt.Service + const revertSvc = yield* SessionRevert.Service + const compactSvc = yield* SessionCompaction.Service + const runState = yield* SessionRunState.Service + const agentSvc = yield* Agent.Service + const permissionSvc = yield* Permission.Service const statusSvc = yield* SessionStatus.Service const todoSvc = yield* Todo.Service const summary = yield* SessionSummary.Service @@ -148,14 +155,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer)), - ), - ), - ) + return yield* shareSvc.create(ctx.payload) }) const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: { @@ -175,14 +175,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + yield* session.remove(ctx.params.sessionID) return true }) @@ -190,60 +183,31 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof UpdatePayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => - Effect.gen(function* () { - const current = yield* svc.get(ctx.params.sessionID) - if (ctx.payload.title !== undefined) { - yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) - } - if (ctx.payload.permission !== undefined) { - yield* svc.setPermission({ - sessionID: ctx.params.sessionID, - permission: Permission.merge(current.permission ?? [], ctx.payload.permission), - }) - } - if (ctx.payload.time?.archived !== undefined) { - yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) - } - return yield* svc.get(ctx.params.sessionID) - }), - ).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + const current = yield* session.get(ctx.params.sessionID) + if (ctx.payload.title !== undefined) { + yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) + } + if (ctx.payload.permission !== undefined) { + yield* session.setPermission({ + sessionID: ctx.params.sessionID, + permission: Permission.merge(current.permission ?? [], ctx.payload.permission), + }) + } + if (ctx.payload.time?.archived !== undefined) { + yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) + } + return yield* session.get(ctx.params.sessionID) }) const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { params: { sessionID: SessionID } payload: typeof ForkPayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => - svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }), - ).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }) }) const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe( - Effect.provide(SessionPrompt.defaultLayer), - ), - ), - ), - ) + yield* promptSvc.cancel(ctx.params.sessionID) return true }) @@ -251,98 +215,45 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof InitPayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.command({ - sessionID: ctx.params.sessionID, - messageID: ctx.payload.messageID, - model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, - command: Command.Default.INIT, - arguments: "", - }), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ) + yield* promptSvc.command({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload.messageID, + model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, + command: Command.Default.INIT, + arguments: "", + }) return true }) const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.share(ctx.params.sessionID) - return yield* session.get(ctx.params.sessionID) - }).pipe(Effect.provide(SessionShare.defaultLayer)), - ), - ), - ) + yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + return yield* session.get(ctx.params.sessionID) }) const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.unshare(ctx.params.sessionID) - return yield* session.get(ctx.params.sessionID) - }).pipe(Effect.provide(SessionShare.defaultLayer)), - ), - ), - ) + yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + return yield* session.get(ctx.params.sessionID) }) const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: { params: { sessionID: SessionID } payload: typeof SummarizePayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const revert = yield* SessionRevert.Service - const compact = yield* SessionCompaction.Service - const prompt = yield* SessionPrompt.Service - const agent = yield* Agent.Service + yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID)) + const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) + const defaultAgent = yield* agentSvc.defaultAgent() + const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent - yield* revert.cleanup(yield* session.get(ctx.params.sessionID)) - const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) - const defaultAgent = yield* agent.defaultAgent() - const currentAgent = - messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent - - yield* compact.create({ - sessionID: ctx.params.sessionID, - agent: currentAgent, - model: { - providerID: ctx.payload.providerID, - modelID: ctx.payload.modelID, - }, - auto: ctx.payload.auto ?? false, - }) - yield* prompt.loop({ sessionID: ctx.params.sessionID }) - }).pipe( - Effect.provide(SessionRevert.defaultLayer), - Effect.provide(SessionCompaction.defaultLayer), - Effect.provide(SessionPrompt.defaultLayer), - Effect.provide(Agent.defaultLayer), - Effect.provide(Session.defaultLayer), - ), - ), - ), - ) + yield* compactSvc.create({ + sessionID: ctx.params.sessionID, + agent: currentAgent, + model: { + providerID: ctx.payload.providerID, + modelID: ctx.payload.modelID, + }, + auto: ctx.payload.auto ?? false, + }) + yield* promptSvc.loop({ sessionID: ctx.params.sessionID }) return true }) @@ -350,19 +261,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - const instance = yield* InstanceState.context + const bridge = yield* EffectBridge.make() return HttpServerResponse.stream( Stream.fromEffect( Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - } as unknown as SessionPrompt.PromptInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), + bridge.promise( + promptSvc.prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + } as unknown as SessionPrompt.PromptInput), ), ), ).pipe( @@ -377,23 +284,23 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - const instance = yield* InstanceState.context + const bridge = yield* EffectBridge.make() yield* Effect.sync(() => { - Instance.restore(instance, () => { - void AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ).catch((error) => { - log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) - void Bus.publish(Session.Event.Error, { - sessionID: ctx.params.sessionID, - error: new NamedError.Unknown({ - message: error instanceof Error ? error.message : String(error), - }).toObject(), - }) - }) - }) + bridge.fork( + promptSvc + .prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput) + .pipe( + Effect.catchCause((error) => + Effect.sync(() => { + log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) + void Bus.publish(Session.Event.Error, { + sessionID: ctx.params.sessionID, + error: new NamedError.Unknown({ message: String(error) }).toObject(), + }) + }), + ), + ), + ) }) return HttpApiSchema.NoContent.make() }) @@ -402,111 +309,47 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ) + return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { params: { sessionID: SessionID } payload: typeof ShellPayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ) + return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput) }) const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: { params: { sessionID: SessionID } payload: typeof RevertPayload.Type }) { - const instance = yield* InstanceState.context - log.info("revert", ctx.payload) - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionRevert.Service.use((svc) => svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })).pipe( - Effect.provide(SessionRevert.defaultLayer), - ), - ), - ), - ) + return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload }) }) const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe( - Effect.provide(SessionRevert.defaultLayer), - ), - ), - ), - ) + return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID }) }) const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: { params: { permissionID: PermissionID } payload: typeof PermissionResponsePayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }), - ).pipe(Effect.provide(Permission.defaultLayer)), - ), - ), - ) + yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }) return true }) const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(ctx.params.sessionID) - yield* session.removeMessage(ctx.params) - }).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)), - ), - ), - ) + yield* runState.assertNotBusy(ctx.params.sessionID) + yield* session.removeMessage(ctx.params) return true }) const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID; partID: PartID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + yield* session.removePart(ctx.params) return true }) @@ -524,14 +367,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", `Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`, ) } - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + return yield* session.updatePart(payload) }) return handlers diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index d8208c7657..600b4f6087 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -22,10 +22,14 @@ import { Provider } from "@/provider/provider" import { Pty } from "@/pty" import { Question } from "@/question" import { Session } from "@/session/session" +import { SessionCompaction } from "@/session/compaction" +import { SessionPrompt } from "@/session/prompt" +import { SessionRevert } from "@/session/revert" import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" +import { SessionShare } from "@/share/session" import { Skill } from "@/skill" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" @@ -134,6 +138,10 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( Question.defaultLayer, Ripgrep.defaultLayer, Session.defaultLayer, + SessionCompaction.defaultLayer, + SessionPrompt.defaultLayer, + SessionRevert.defaultLayer, + SessionShare.defaultLayer, SessionRunState.defaultLayer, SessionStatus.defaultLayer, SessionSummary.defaultLayer, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 9a50a9a980..72c4d241eb 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -589,7 +589,7 @@ export const layer: Layer.Layer = path: sessionPath(ctx.worktree, ctx.directory), title: input?.title, permission: input?.permission, - workspaceID: workspace, + workspaceID: input?.workspaceID ?? workspace, }) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 75e4a3ac9b..c7d0945436 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -3,9 +3,13 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" +import { registerAdaptor } from "../../src/control-plane/adaptors" +import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" @@ -22,6 +26,7 @@ import { it } from "../lib/effect" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES function app(experimental = true) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental @@ -77,6 +82,28 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string ) } +const localAdaptor = (directory: string): WorkspaceAdaptor => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + Effect.promise(async () => { + registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) + return Workspace.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }) + }) + function request(path: string, init?: RequestInit) { return Effect.promise(async () => app().request(path, init)) } @@ -108,6 +135,7 @@ function withTmp( afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await Instance.disposeAll() await resetDatabase() }) @@ -226,6 +254,40 @@ describe("session HttpApi", () => { ), ) + it.live( + "persists selected workspace id when creating a session", + withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const project = yield* Project.use.fromDirectory(tmp.path).pipe(Effect.provide(Project.defaultLayer)) + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "session-create-workspace", + directory: path.join(tmp.path, ".workspace-local"), + }) + + const created = yield* requestJson(`${SessionPaths.create}?workspace=${workspace.id}`, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ title: "workspace session" }), + }) + + expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect( + yield* Effect.sync(() => + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, created.id)) + .get(), + ), + ), + ).toEqual({ workspaceID: workspace.id }) + }), + ), + ) + it.live( "matches legacy archived timestamp validation", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>