feat(sdk): expose active sessions (#33991)

This commit is contained in:
Kit Langton 2026-06-26 05:33:35 +02:00 committed by GitHub
parent 40ecfceec2
commit ef5c9f4931
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 236 additions and 71 deletions

View file

@ -174,6 +174,7 @@ _Avoid_: Response envelope
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.

View file

@ -55,43 +55,49 @@ const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1In
Effect.map((value) => value.data),
)
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
const Endpoint0_2 = (raw: RawClient["server.session"]) => () =>
raw["session.active"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] }
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint0_3Input = {
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
readonly agent: Endpoint0_3Request["payload"]["agent"]
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint0_4Input = {
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
readonly agent: Endpoint0_4Request["payload"]["agent"]
}
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint0_4Input = {
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
readonly model: Endpoint0_4Request["payload"]["model"]
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint0_5Input = {
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
readonly model: Endpoint0_5Request["payload"]["model"]
}
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_5Input = {
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
readonly id?: Endpoint0_5Request["payload"]["id"]
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
readonly resume?: Endpoint0_5Request["payload"]["resume"]
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_6Input = {
readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
readonly id?: Endpoint0_6Request["payload"]["id"]
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
readonly resume?: Endpoint0_6Request["payload"]["resume"]
}
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
raw["session.prompt"]({
params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
@ -100,23 +106,23 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp
Effect.map((value) => value.data),
)
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_8Input = {
readonly sessionID: Endpoint0_8Request["params"]["sessionID"]
readonly messageID: Endpoint0_8Request["payload"]["messageID"]
readonly files?: Endpoint0_8Request["payload"]["files"]
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_9Input = {
readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
readonly messageID: Endpoint0_9Request["payload"]["messageID"]
readonly files?: Endpoint0_9Request["payload"]["files"]
}
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
raw["session.revert.stage"]({
params: { sessionID: input.sessionID },
payload: { messageID: input.messageID, files: input.files },
@ -125,30 +131,30 @@ const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Inp
Effect.map((value) => value.data),
)
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
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 Endpoint0_11Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint0_12Input = {
readonly sessionID: Endpoint0_12Request["params"]["sessionID"]
readonly after?: Endpoint0_12Request["query"]["after"]
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint0_13Input = {
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
readonly after?: Endpoint0_13Request["query"]["after"]
}
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
Stream.unwrap(
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
Effect.mapError(mapClientError),
@ -156,17 +162,17 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I
),
)
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] }
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint0_14Input = {
readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
readonly messageID: Endpoint0_14Request["params"]["messageID"]
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint0_15Input = {
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
readonly messageID: Endpoint0_15Request["params"]["messageID"]
}
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@ -175,19 +181,20 @@ const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14I
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
list: Endpoint0_0(raw),
create: Endpoint0_1(raw),
get: Endpoint0_2(raw),
switchAgent: Endpoint0_3(raw),
switchModel: Endpoint0_4(raw),
prompt: Endpoint0_5(raw),
compact: Endpoint0_6(raw),
wait: Endpoint0_7(raw),
stage: Endpoint0_8(raw),
clear: Endpoint0_9(raw),
commit: Endpoint0_10(raw),
context: Endpoint0_11(raw),
events: Endpoint0_12(raw),
interrupt: Endpoint0_13(raw),
message: Endpoint0_14(raw),
active: Endpoint0_2(raw),
get: Endpoint0_3(raw),
switchAgent: Endpoint0_4(raw),
switchModel: Endpoint0_5(raw),
prompt: Endpoint0_6(raw),
compact: Endpoint0_7(raw),
wait: Endpoint0_8(raw),
stage: Endpoint0_9(raw),
clear: Endpoint0_10(raw),
commit: Endpoint0_11(raw),
context: Endpoint0_12(raw),
events: Endpoint0_13(raw),
interrupt: Endpoint0_14(raw),
message: Endpoint0_15(raw),
})
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })

View file

@ -3,6 +3,7 @@ import type {
SessionsListOutput,
SessionsCreateInput,
SessionsCreateOutput,
SessionsActiveOutput,
SessionsGetInput,
SessionsGetOutput,
SessionsSwitchAgentInput,
@ -198,6 +199,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
active: (requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsActiveOutput }>(
{
method: "GET",
path: `/api/session/active`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsGetOutput }>(
{

View file

@ -243,6 +243,8 @@ export type SessionsCreateOutput = {
}
}["data"]
export type SessionsActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"]
export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsGetOutput = {

View file

@ -37,6 +37,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
if (url.includes("/message/")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage })))
}
if (url.endsWith("/api/session/active")) {
return Effect.succeed(
HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
)
}
if (request.method === "POST" && url.endsWith("/api/session")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
}
@ -50,6 +55,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const page = yield* client.sessions.list({ limit: 10 })
const active = yield* client.sessions.active()
const created = yield* client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
@ -74,10 +80,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"),
})
return { page, created, admitted, context, events, message }
return { page, active, created, admitted, context, events, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")

View file

@ -32,6 +32,7 @@ test("session methods use the public HTTP contract", async () => {
if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } })
@ -39,6 +40,7 @@ test("session methods use the public HTTP contract", async () => {
})
const page = await client.sessions.list({ limit: "10", order: "desc" })
const active = await client.sessions.active()
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.sessions.switchModel({
@ -59,6 +61,7 @@ test("session methods use the public HTTP contract", async () => {
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(context).toEqual([])
@ -66,6 +69,7 @@ test("session methods use the public HTTP contract", async () => {
expect(message).toEqual(modelSwitchedMessage)
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
["GET", "http://localhost:3000/api/session/active"],
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
@ -77,7 +81,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
])
const body = requests[4]?.init?.body
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
if (typeof body !== "string") throw new Error("Expected JSON request body")
expect(JSON.parse(body)).toEqual({
prompt: { text: "Hello" },

View file

@ -155,6 +155,7 @@ export interface Interface {
}) => Effect.Effect<void, OperationUnavailableError>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly revert: {
@ -402,6 +403,7 @@ export const layer = Layer.unwrap(
yield* result.get(sessionID)
return yield* new OperationUnavailableError({ operation: "wait" })
}),
active: execution.active,
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.resume(sessionID)

View file

@ -5,6 +5,8 @@ import { SessionRunner } from "./runner/index"
import { SessionSchema } from "./schema"
export interface Interface {
/** Snapshots active execution owned by this process. */
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
/** Starts execution while idle or joins the active execution. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
@ -19,5 +21,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Low-level compatibility layer for callers that only need durable Session recording. */
export const noopLayer = Layer.succeed(
Service,
Service.of({ resume: () => Effect.void, wake: () => Effect.void, interrupt: () => Effect.void }),
Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.void,
}),
)

View file

@ -28,6 +28,7 @@ export const layer = Layer.effect(
})
return SessionExecution.Service.of({
active: coordinator.active,
interrupt: coordinator.interrupt,
resume: coordinator.run,
wake: coordinator.wake,

View file

@ -4,6 +4,8 @@ import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
/** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E> {
/** Snapshots keys with an execution owned by this coordinator. */
readonly active: Effect.Effect<ReadonlySet<Key>>
/** Starts execution while idle or joins the active execution. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Registers one coalesced follow-up after newly recorded work. */
@ -98,5 +100,5 @@ export const make = <Key, E>(options: {
return Fiber.interrupt(entry.owner)
})
return { run, wake, interrupt }
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
})

View file

@ -22,9 +22,11 @@ import { testEffect } from "./lib/effect"
const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = []
const wakeCalls: SessionV2.ID[] = []
const activeSessions = new Set<SessionV2.ID>()
const execution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.sync(() => new Set(activeSessions)),
resume: (sessionID) =>
Effect.sync(() => {
executionCalls.push(sessionID)
@ -108,6 +110,13 @@ const eventCount = (type: string) =>
)
describe("SessionV2.prompt", () => {
it.effect("exposes the execution registry", () =>
Effect.gen(function* () {
activeSessions.add(sessionID)
expect(Array.from(yield* (yield* SessionV2.Service).active)).toEqual([sessionID])
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
)
it.effect("delegates execution continuation through SessionExecution", () =>
Effect.gen(function* () {
yield* setup

View file

@ -66,6 +66,78 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("snapshots only active executions", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const firstGate = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: (key: string) =>
Deferred.succeed(key === "first" ? firstStarted : secondStarted, undefined).pipe(
Effect.andThen(Deferred.await(key === "first" ? firstGate : secondGate)),
),
})
expect(Array.from(yield* coordinator.active)).toEqual([])
const first = yield* coordinator.run("first").pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
expect(Array.from(yield* coordinator.active)).toEqual(["first"])
const second = yield* coordinator.run("second").pipe(Effect.forkChild)
yield* Deferred.await(secondStarted)
expect(Array.from(yield* coordinator.active)).toEqual(["first", "second"])
yield* Deferred.succeed(firstGate, undefined)
yield* Fiber.join(first)
expect(Array.from(yield* coordinator.active)).toEqual(["second"])
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(second)
expect(Array.from(yield* coordinator.active)).toEqual([])
}),
),
)
it.effect("cleans active executions after failure and defect", () =>
Effect.scoped(
Effect.gen(function* () {
const failure = new Error("failed")
const defect = new Error("defect")
const coordinator = yield* SessionRunCoordinator.make({
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
})
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
expect(Exit.isFailure(failed) && Cause.hasFails(failed.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
const died = yield* coordinator.run("defect").pipe(Effect.exit)
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
}),
),
)
it.effect("cleans active executions when its scope closes", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const coordinator = yield* Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
return coordinator
}),
)
expect(Array.from(yield* coordinator.active)).toEqual([])
}),
)
it.effect("coalesces wakes received during active execution", () =>
Effect.scoped(
Effect.gen(function* () {
@ -166,6 +238,7 @@ describe("SessionRunCoordinator", () => {
const exit = yield* Fiber.await(resumed)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(runs).toBe(1)
}),
),

View file

@ -95,6 +95,7 @@ const execution = Layer.effect(
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,

View file

@ -254,6 +254,7 @@ const execution = Layer.effect(
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,

View file

@ -964,6 +964,7 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.status(400, undefined, "none"),
http.protected.get("/api/session/active", "v2.session.active").json(200, data(object), "none"),
http.protected
.post("/api/session", "v2.session.create")
.at((ctx) => ({

View file

@ -73,6 +73,10 @@ export const SessionsCursor = Schema.String.pipe(
)
export type SessionsCursor = typeof SessionsCursor.Type
const SessionActive = Schema.Struct({
type: Schema.Literal("running"),
}).annotate({ identifier: "SessionActive" })
const SessionsQueryCursor = SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
})
@ -124,6 +128,18 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.active", "/api/session/active", {
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.active",
summary: "List active sessions",
description:
"Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.",
}),
),
)
.add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
params: { sessionID: Session.ID },

View file

@ -33,6 +33,7 @@ test("embedded client uses the real router and handlers", async () => {
yield* opencode.sessions.switchModel({ sessionID, model })
const selected = yield* opencode.sessions.get({ sessionID })
const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) })
const active = yield* opencode.sessions.active()
const admitted = yield* opencode.sessions.prompt({
sessionID,
prompt: Prompt.make({ text: "Do not run" }),
@ -81,6 +82,7 @@ test("embedded client uses the real router and handlers", async () => {
expect(selected.model?.id).toBe(model.id)
expect(selected.model?.providerID).toBe(model.providerID)
expect(page.data.some((session) => session.id === sessionID)).toBe(true)
expect(active).toEqual({})
expect(admitted.sessionID).toBe(sessionID)
expect(prompted.type).toBe("session.next.prompted")
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))

View file

@ -76,6 +76,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.active",
Effect.fn(function* () {
return {
data: Object.fromEntries(
Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]),
),
}
}),
)
.handle(
"session.get",
Effect.fn(function* (ctx) {

View file

@ -25,6 +25,11 @@ sessions.interrupt(sessionID)
-> clears a coalesced follow-up wake already registered with this coordinator
-> preserves durable inbox rows for a later wake or resume
-> idle or missing Session is a no-op
sessions.active()
-> snapshots foreground Session drains owned by this process
-> returns only active Session IDs with { type: "running" }
-> absence means inactive; activity is not durable across process restarts
```
`session_input` is the durable admission inbox. `PromptAdmitted` records and projects accepted input so pending queue state can be replayed, replicated, and observed by clients. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts.
@ -161,6 +166,8 @@ Post-crash continuation recovery is intentionally deferred. A wake does not infe
A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new provider turn against that Location.
The coordinator's active registry is also the source for `sessions.active()`. It represents only foreground Session drains owned by the current process; background subagents and tasks do not add parent Sessions to this registry. The snapshot is runtime state and is empty after a process restart.
Inbox promotion coalesces pending steers in durable admission order. Once continuation would otherwise end, it promotes one queued input at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth.
Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets.