diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 024c9782806c..2918a4bb15b6 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -123,23 +123,28 @@ const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Inp Effect.map((value) => value.data), ) -type Endpoint3_7Request = Parameters[0] +type Endpoint3_7Request = Parameters[0] type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] } const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.resume"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_8Request = Parameters[0] +type Endpoint3_8Request = Parameters[0] type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] } const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_9Request = Parameters[0] +type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] } +const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_9Request = Parameters[0] -type Endpoint3_9Input = { - readonly sessionID: Endpoint3_9Request["params"]["sessionID"] - readonly messageID: Endpoint3_9Request["payload"]["messageID"] - readonly files?: Endpoint3_9Request["payload"]["files"] +type Endpoint3_10Request = Parameters[0] +type Endpoint3_10Input = { + readonly sessionID: Endpoint3_10Request["params"]["sessionID"] + readonly messageID: Endpoint3_10Request["payload"]["messageID"] + readonly files?: Endpoint3_10Request["payload"]["files"] } -const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) => +const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -148,42 +153,42 @@ const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Inp Effect.map((value) => value.data), ) -type Endpoint3_10Request = Parameters[0] -type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] } -const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint3_11Request = Parameters[0] +type Endpoint3_11Request = Parameters[0] type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] } const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_12Request = Parameters[0] +type Endpoint3_12Request = Parameters[0] type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] } const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_13Request = Parameters[0] +type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] } +const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint3_13Request = Parameters[0] -type Endpoint3_13Input = { - readonly sessionID: Endpoint3_13Request["params"]["sessionID"] - readonly limit?: Endpoint3_13Request["query"]["limit"] - readonly after?: Endpoint3_13Request["query"]["after"] +type Endpoint3_14Request = Parameters[0] +type Endpoint3_14Input = { + readonly sessionID: Endpoint3_14Request["params"]["sessionID"] + readonly limit?: Endpoint3_14Request["query"]["limit"] + readonly after?: Endpoint3_14Request["query"]["after"] } -const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) => +const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) => raw["session.history"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], after: input["after"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_14Request = Parameters[0] -type Endpoint3_14Input = { - readonly sessionID: Endpoint3_14Request["params"]["sessionID"] - readonly after?: Endpoint3_14Request["query"]["after"] +type Endpoint3_15Request = Parameters[0] +type Endpoint3_15Input = { + readonly sessionID: Endpoint3_15Request["params"]["sessionID"] + readonly after?: Endpoint3_15Request["query"]["after"] } -const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) => +const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( Effect.mapError(mapClientError), @@ -191,17 +196,17 @@ const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14I ), ) -type Endpoint3_15Request = Parameters[0] -type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] } -const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => +type Endpoint3_16Request = Parameters[0] +type Endpoint3_16Input = { readonly sessionID: Endpoint3_16Request["params"]["sessionID"] } +const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_16Request = Parameters[0] -type Endpoint3_16Input = { - readonly sessionID: Endpoint3_16Request["params"]["sessionID"] - readonly messageID: Endpoint3_16Request["params"]["messageID"] +type Endpoint3_17Request = Parameters[0] +type Endpoint3_17Input = { + readonly sessionID: Endpoint3_17Request["params"]["sessionID"] + readonly messageID: Endpoint3_17Request["params"]["messageID"] } -const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => +const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -215,16 +220,17 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({ switchAgent: Endpoint3_4(raw), switchModel: Endpoint3_5(raw), prompt: Endpoint3_6(raw), - compact: Endpoint3_7(raw), - wait: Endpoint3_8(raw), - stage: Endpoint3_9(raw), - clear: Endpoint3_10(raw), - commit: Endpoint3_11(raw), - context: Endpoint3_12(raw), - history: Endpoint3_13(raw), - events: Endpoint3_14(raw), - interrupt: Endpoint3_15(raw), - message: Endpoint3_16(raw), + resume: Endpoint3_7(raw), + compact: Endpoint3_8(raw), + wait: Endpoint3_9(raw), + stage: Endpoint3_10(raw), + clear: Endpoint3_11(raw), + commit: Endpoint3_12(raw), + context: Endpoint3_13(raw), + history: Endpoint3_14(raw), + events: Endpoint3_15(raw), + interrupt: Endpoint3_16(raw), + message: Endpoint3_17(raw), }) type Endpoint4_0Request = Parameters[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 27ec3d81ba2c..91023eda33eb 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -17,6 +17,8 @@ import type { SessionsSwitchModelOutput, SessionsPromptInput, SessionsPromptOutput, + SessionsResumeInput, + SessionsResumeOutput, SessionsCompactInput, SessionsCompactOutput, SessionsWaitInput, @@ -379,6 +381,17 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + resume: (input: SessionsResumeInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/resume`, + successStatus: 204, + declaredStatuses: [404, 503, 400, 401], + empty: true, + }, + requestOptions, + ), compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3b3188c8742a..99e91902ae0c 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -482,6 +482,10 @@ export type SessionsPromptOutput = { } }["data"] +export type SessionsResumeInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsResumeOutput = void + export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionsCompactOutput = void diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 9d7643cb3309..da0dc38a0015 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -4,7 +4,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Cause, Config, Effect, Exit, Layer } from "effect" +import { Cause, Config, Effect, Exit, Fiber, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -34,6 +34,7 @@ import * as DateTime from "effect/DateTime" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { cliIt } from "../lib/cli-process" import { TestLLMServer } from "../lib/llm-server" import { testProviderConfig } from "../lib/test-provider" import { pollWithTimeout, testEffect } from "../lib/effect" @@ -427,6 +428,161 @@ describe("session HttpApi", () => { }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), ) + cliIt.live( + "resumes a persisted session after the process restarts without creating a user message", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const config = testProviderConfig(llm.url) + const env = { + OPENCODE_PURE: "0", + OPENCODE_DB: path.join(home, "opencode.db"), + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...config, + provider: { + test: { + ...config.provider.test, + env: undefined, + }, + }, + permission: { "*": "allow" }, + }), + } + const configDirectory = path.join(home, ".config", "opencode") + yield* Effect.promise(() => mkdir(configDirectory, { recursive: true })) + yield* Effect.promise(() => Bun.write(path.join(configDirectory, "opencode.json"), env.OPENCODE_CONFIG_CONTENT)) + const api = (base: string, path: string, init?: RequestInit) => + Effect.promise(() => fetch(new URL(path, base), init)) + const body = (response: Response) => Effect.promise(() => response.json() as Promise) + const headers = { "x-opencode-directory": home, "content-type": "application/json" } + const prompt = "Resume this session after restart" + const server = yield* opencode.serve({ env }) + yield* pollWithTimeout( + api(server.url, "/api/model", { headers }).pipe( + Effect.flatMap(body<{ data: { id: string; providerID: string }[] }>), + Effect.map((response) => + response.data.find((model) => model.providerID === "test" && model.id === "test-model"), + ), + ), + "server did not load its configured model", + "10 seconds", + ) + const createdResponse = yield* api(server.url, "/api/session", { + method: "POST", + headers, + body: JSON.stringify({ + model: { providerID: "test", id: "test-model" }, + location: { directory: home }, + }), + }) + expect(createdResponse.status).toBe(200) + const created = yield* body<{ data: { id: string } }>(createdResponse) + const sessionID = created.data.id + + expect( + (yield* api(server.url, `/api/session/${sessionID}/prompt`, { + method: "POST", + headers, + body: JSON.stringify({ prompt: { text: prompt }, resume: false }), + })).status, + ).toBe(200) + + server.kill() + yield* Effect.promise(() => server.exited) + const restarted = yield* opencode.serve({ env }) + yield* pollWithTimeout( + api(restarted.url, "/api/model", { headers }).pipe( + Effect.flatMap(body<{ data: { id: string; providerID: string }[] }>), + Effect.map((response) => + response.data.find((model) => model.providerID === "test" && model.id === "test-model"), + ), + ), + "restarted process did not reload its configured model", + "10 seconds", + ) + yield* pollWithTimeout( + api(restarted.url, `/api/session/${sessionID}`, { headers }).pipe( + Effect.flatMap((response) => + response.ok ? body<{ data: { id: string } }>(response) : Effect.succeed(undefined), + ), + Effect.map((response) => (response?.data.id === sessionID ? response.data : undefined)), + ), + "restarted process did not import its session", + "10 seconds", + ) + yield* llm.tool("question", { + questions: [ + { + header: "Continue", + question: "Continue the imported session?", + options: [{ label: "Continue", description: "Resume from the recorded history" }], + }, + ], + }) + yield* llm.text("Resumed after restart") + + const resumed = yield* api(restarted.url, `/api/session/${sessionID}/resume`, { + method: "POST", + headers, + }).pipe(Effect.forkChild) + const pending = yield* Effect.raceFirst( + pollWithTimeout( + api(restarted.url, `/api/session/${sessionID}/question`, { headers }).pipe( + Effect.flatMap(body<{ data: { id: string }[] }>), + Effect.map((response) => response.data[0]), + ), + "resumed session did not ask its question", + "10 seconds", + ), + Fiber.join(resumed).pipe( + Effect.flatMap((response) => + Effect.promise(() => response.text()).pipe( + Effect.flatMap((text) => + Effect.fail(new Error(`session resume returned ${response.status} before asking: ${text}`)), + ), + ), + ), + ), + ) + expect( + (yield* api(restarted.url, `/api/session/${sessionID}/question/${pending.id}/reply`, { + method: "POST", + headers, + body: JSON.stringify({ answers: [["Continue"]] }), + })).status, + ).toBe(204) + expect((yield* Fiber.join(resumed)).status).toBe(204) + + const context = yield* api(restarted.url, `/api/session/${sessionID}/context`, { headers }).pipe( + Effect.flatMap(body<{ data: SessionMessage.Message[] }>), + ) + expect(context.data.filter((message) => message.type === "user").map((message) => message.text)).toEqual([ + prompt, + ]) + expect(context.data.at(-1)).toMatchObject({ + type: "assistant", + finish: "stop", + content: [{ type: "text", text: "Resumed after restart" }], + }) + + const requestUserTexts = (input: Record) => { + if (!Array.isArray(input.messages)) return [] + return input.messages.flatMap((message) => { + if (!message || typeof message !== "object" || !("role" in message) || message.role !== "user") return [] + if (!("content" in message)) return [] + if (typeof message.content === "string") return [message.content] + if (!Array.isArray(message.content)) return [] + return message.content.flatMap((part: unknown) => + part && typeof part === "object" && "text" in part && typeof part.text === "string" ? [part.text] : [], + ) + }) + } + const sessionRequests = (yield* llm.inputs).map(requestUserTexts).filter((texts) => texts.includes(prompt)) + expect(sessionRequests).toHaveLength(2) + expect(sessionRequests).toEqual([[prompt], [prompt]]) + }), + 60_000, + ) + it.instance( "returns v2 public request errors for cursor and workspace query failures", () => diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 8ce85ef79686..f212232e2b63 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -222,6 +222,21 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.resume", "/api/session/:sessionID/resume", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.resume", + summary: "Resume session", + description: "Resume an existing session from recorded history.", + }), + ), + ) .add( HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: Session.ID }, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 5b7d354b04fc..da0b5d54d5d7 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -169,6 +169,25 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.resume", + Effect.fn(function* (ctx) { + yield* session.resume(ctx.params.sessionID).pipe( + Effect.mapError((error) => + error._tag === "Session.NotFoundError" + ? new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }) + : new ServiceUnavailableError({ + message: error.message, + service: "session.resume", + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.compact", Effect.fn(function* (ctx) {