Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 52 additions & 46 deletions packages/client/src/generated-effect/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,23 +123,28 @@ const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Inp
Effect.map((value) => value.data),
)

type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.resume"]>[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<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.compact"]>[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<RawClient["server.session"]["session.wait"]>[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<RawClient["server.session"]["session.revert.stage"]>[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<RawClient["server.session"]["session.revert.stage"]>[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"] },
Expand All @@ -148,60 +153,60 @@ const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Inp
Effect.map((value) => value.data),
)

type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[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<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[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<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[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<RawClient["server.session"]["session.context"]>[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<RawClient["server.session"]["session.history"]>[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<RawClient["server.session"]["session.history"]>[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<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_14Input = {
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
readonly after?: Endpoint3_14Request["query"]["after"]
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.events"]>[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),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
),
)

type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.interrupt"]>[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<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_16Input = {
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
readonly messageID: Endpoint3_16Request["params"]["messageID"]
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.message"]>[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),
Expand All @@ -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<RawClient["server.message"]["session.messages"]>[0]
Expand Down
13 changes: 13 additions & 0 deletions packages/client/src/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type {
SessionsSwitchModelOutput,
SessionsPromptInput,
SessionsPromptOutput,
SessionsResumeInput,
SessionsResumeOutput,
SessionsCompactInput,
SessionsCompactOutput,
SessionsWaitInput,
Expand Down Expand Up @@ -379,6 +381,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
resume: (input: SessionsResumeInput, requestOptions?: RequestOptions) =>
request<SessionsResumeOutput>(
{
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<SessionsCompactOutput>(
{
Expand Down
4 changes: 4 additions & 0 deletions packages/client/src/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 157 additions & 1 deletion packages/opencode/test/server/httpapi-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 = <T>(response: Response) => Effect.promise(() => response.json() as Promise<T>)
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<string, unknown>) => {
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",
() =>
Expand Down
15 changes: 15 additions & 0 deletions packages/protocol/src/groups/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,21 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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 },
Expand Down
Loading
Loading