From 2d1d4530216d26bd409ddf3f0163f323e4fb54c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Firat=20=C3=96zcan?= Date: Sun, 30 Aug 2026 00:13:30 +0200 Subject: [PATCH 1/3] test(server): cover session resume after restart --- .../test/server/httpapi-session.test.ts | 106 +++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 9d7643cb3309..e6d3fc8d07d1 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" @@ -33,7 +33,13 @@ import { ProviderV2 } from "@opencode-ai/core/provider" 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 { + disposeAllInstances, + provideInstanceEffect, + reloadInstance, + TestInstance, + tmpdirScoped, +} from "../fixture/fixture" import { TestLLMServer } from "../lib/llm-server" import { testProviderConfig } from "../lib/test-provider" import { pollWithTimeout, testEffect } from "../lib/effect" @@ -427,6 +433,102 @@ describe("session HttpApi", () => { }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), ) + it.live("resumes an existing session after the process restarts without creating a user message", () => + Effect.gen(function* () { + const llm = yield* TestLLMServer + const directory = yield* tmpdirScoped({ + git: true, + config: { ...testProviderConfig(llm.url), permission: { "*": "allow" } }, + }) + const headers = { "x-opencode-directory": directory, "content-type": "application/json" } + const prompt = "Resume this session after restart" + const created = yield* requestJson<{ data: { id: string } }>("/api/session", { + method: "POST", + headers, + body: JSON.stringify({ + model: { providerID: "test", id: "test-model" }, + location: { directory }, + }), + }) + const sessionID = created.data.id + + expect( + ( + yield* request(`/api/session/${sessionID}/prompt`, { + method: "POST", + headers, + body: JSON.stringify({ prompt: { text: prompt }, resume: false }), + }) + ).status, + ).toBe(200) + + yield* llm.fail("Provider unavailable") + expect((yield* request(`/api/session/${sessionID}/resume`, { method: "POST", headers })).status).toBe(503) + + yield* reloadInstance({ directory }) + 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* request(`/api/session/${sessionID}/resume`, { method: "POST", headers }).pipe( + Effect.forkChild, + ) + const pending = yield* pollWithTimeout( + requestJson<{ data: { id: string }[] }>(`/api/session/${sessionID}/question`, { headers }).pipe( + Effect.map((response) => response.data[0]), + ), + "resumed session did not ask its question", + "10 seconds", + ) + expect( + ( + yield* request(`/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* requestJson<{ data: SessionMessage.Message[] }>( + `/api/session/${sessionID}/context`, + { headers }, + ) + 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) => + 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(3) + expect(sessionRequests).toEqual([[prompt], [prompt], [prompt]]) + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), + ) + it.instance( "returns v2 public request errors for cursor and workspace query failures", () => From 6dae6d7af543e19340f158ac1aea049158e80cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Firat=20=C3=96zcan?= Date: Sun, 30 Aug 2026 00:22:40 +0200 Subject: [PATCH 2/3] feat(server): expose session resume --- .../client/src/generated-effect/client.ts | 98 ++++---- packages/client/src/generated/client.ts | 13 + packages/client/src/generated/types.ts | 4 + .../test/server/httpapi-session.test.ts | 222 +++++++++++------- packages/protocol/src/groups/session.ts | 15 ++ packages/server/src/handlers/session.ts | 19 ++ 6 files changed, 236 insertions(+), 135 deletions(-) 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 e6d3fc8d07d1..dc114b48c892 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -33,13 +33,8 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" -import { - disposeAllInstances, - provideInstanceEffect, - reloadInstance, - TestInstance, - tmpdirScoped, -} from "../fixture/fixture" +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" @@ -433,100 +428,149 @@ describe("session HttpApi", () => { }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), ) - it.live("resumes an existing session after the process restarts without creating a user message", () => - Effect.gen(function* () { - const llm = yield* TestLLMServer - const directory = yield* tmpdirScoped({ - git: true, - config: { ...testProviderConfig(llm.url), permission: { "*": "allow" } }, - }) - const headers = { "x-opencode-directory": directory, "content-type": "application/json" } - const prompt = "Resume this session after restart" - const created = yield* requestJson<{ data: { id: string } }>("/api/session", { - method: "POST", - headers, - body: JSON.stringify({ - model: { providerID: "test", id: "test-model" }, - location: { directory }, - }), - }) - const sessionID = created.data.id + cliIt.live( + "resumes an existing session after the process restarts without creating a user message", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const env = { + OPENCODE_PURE: "0", + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...testProviderConfig(llm.url), + 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 start = () => opencode.serve({ env }) + 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* start() + 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* request(`/api/session/${sessionID}/prompt`, { + expect( + (yield* api(server.url, `/api/session/${sessionID}/prompt`, { method: "POST", headers, body: JSON.stringify({ prompt: { text: prompt }, resume: false }), - }) - ).status, - ).toBe(200) + })).status, + ).toBe(200) - yield* llm.fail("Provider unavailable") - expect((yield* request(`/api/session/${sessionID}/resume`, { method: "POST", headers })).status).toBe(503) + yield* llm.fail("Provider unavailable") + expect((yield* api(server.url, `/api/session/${sessionID}/resume`, { method: "POST", headers })).status).toBe( + 503, + ) - yield* reloadInstance({ directory }) - 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") + server.kill() + yield* Effect.promise(() => server.exited) + const restarted = yield* start() + 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* 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* request(`/api/session/${sessionID}/resume`, { method: "POST", headers }).pipe( - Effect.forkChild, - ) - const pending = yield* pollWithTimeout( - requestJson<{ data: { id: string }[] }>(`/api/session/${sessionID}/question`, { headers }).pipe( - Effect.map((response) => response.data[0]), - ), - "resumed session did not ask its question", - "10 seconds", - ) - expect( - ( - yield* request(`/api/session/${sessionID}/question/${pending.id}/reply`, { + 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* requestJson<{ data: SessionMessage.Message[] }>( - `/api/session/${sessionID}/context`, - { headers }, - ) - 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" }], - }) + })).status, + ).toBe(204) + expect((yield* Fiber.join(resumed)).status).toBe(204) - 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) => - part && typeof part === "object" && "text" in part && typeof part.text === "string" ? [part.text] : [], - ) + 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 sessionRequests = (yield* llm.inputs).map(requestUserTexts).filter((texts) => texts.includes(prompt)) - expect(sessionRequests).toHaveLength(3) - expect(sessionRequests).toEqual([[prompt], [prompt], [prompt]]) - }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), + + 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) => + 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(3) + expect(sessionRequests).toEqual([[prompt], [prompt], [prompt]]) + }), + 60_000, ) it.instance( 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) { From 9d3e3a4029fb3bef7a99f530e858cba72eb5e055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Firat=20=C3=96zcan?= Date: Sun, 30 Aug 2026 00:49:18 +0200 Subject: [PATCH 3/3] test(server): fix session resume process fixture --- .../test/server/httpapi-session.test.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index dc114b48c892..da0dc38a0015 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -429,28 +429,33 @@ describe("session HttpApi", () => { ) cliIt.live( - "resumes an existing session after the process restarts without creating a user message", + "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({ - ...testProviderConfig(llm.url), + ...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 start = () => opencode.serve({ env }) + 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* start() + const server = yield* opencode.serve({ env }) yield* pollWithTimeout( api(server.url, "/api/model", { headers }).pipe( Effect.flatMap(body<{ data: { id: string; providerID: string }[] }>), @@ -481,14 +486,9 @@ describe("session HttpApi", () => { })).status, ).toBe(200) - yield* llm.fail("Provider unavailable") - expect((yield* api(server.url, `/api/session/${sessionID}/resume`, { method: "POST", headers })).status).toBe( - 503, - ) - server.kill() yield* Effect.promise(() => server.exited) - const restarted = yield* start() + const restarted = yield* opencode.serve({ env }) yield* pollWithTimeout( api(restarted.url, "/api/model", { headers }).pipe( Effect.flatMap(body<{ data: { id: string; providerID: string }[] }>), @@ -499,6 +499,16 @@ describe("session HttpApi", () => { "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: [ { @@ -561,14 +571,14 @@ describe("session HttpApi", () => { if (!("content" in message)) return [] if (typeof message.content === "string") return [message.content] if (!Array.isArray(message.content)) return [] - return message.content.flatMap((part) => + 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(3) - expect(sessionRequests).toEqual([[prompt], [prompt], [prompt]]) + expect(sessionRequests).toHaveLength(2) + expect(sessionRequests).toEqual([[prompt], [prompt]]) }), 60_000, )