Compare commits

...
16 changed files with 115 additions and 14 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
"@opencode-ai/plugin": minor
"@opencode-ai/schema": minor
"@opencode-ai/sdk": minor
---
Expose process-local execution status on session get responses.
+5 -2
View File
@@ -29,7 +29,10 @@ afterEach(() => mock.restore())
describe("session target resolver", () => {
test("adopts an explicit Session location and model", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = session("ses_resume", "/session", "work_1", { providerID: "openai", id: "gpt-5" })
const selected = {
...session("ses_resume", "/session", "work_1", { providerID: "openai", id: "gpt-5" }),
executing: false,
}
spyOn(client.session, "get").mockResolvedValue(selected)
spyOn(client.location, "get").mockResolvedValue(location("/session", "work_1"))
@@ -57,7 +60,7 @@ describe("session target resolver", () => {
test("attaches the terminal environment to the resolved local Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = session("ses_resume", "/session")
const selected = { ...session("ses_resume", "/session"), executing: false }
spyOn(client.session, "get").mockResolvedValue(selected)
spyOn(client.location, "get").mockResolvedValue(location("/session"))
const environment = spyOn(client.session, "environment").mockResolvedValue()
+1 -1
View File
@@ -203,7 +203,7 @@ export type SessionActiveOutput = { readonly [x: Session.ID]: { readonly type: "
export type SessionActiveOperation<E = never> = () => Effect.Effect<SessionActiveOutput, E>
export type SessionGetInput = { readonly sessionID: Session.ID }
export type SessionGetOutput = Session.Info
export type SessionGetOutput = Session.GetInfo
export type SessionGetOperation<E = never> = (input: SessionGetInput) => Effect.Effect<SessionGetOutput, E>
export type SessionRemoveInput = { readonly sessionID: Session.ID }
+19 -1
View File
@@ -1545,6 +1545,24 @@ export type SessionInfo = {
revert?: SessionRevert
}
export type SessionGetInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
revert?: SessionRevert
executing: boolean
}
export type SessionRevertStaged = {
id: string
created: number
@@ -3399,7 +3417,7 @@ export type SessionActiveOutput = { data: { [x: string]: SessionActive } }["data
export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionGetOutput = { data: SessionInfo }["data"]
export type SessionGetOutput = { data: SessionGetInfo }["data"]
export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type SessionGetOutput = Session.Info")
expect(source).toContain("export type SessionGetOutput = Session.GetInfo")
expect(source).toContain("export type EventSubscribeOutput = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi")
})
+2
View File
@@ -37,6 +37,7 @@ test("session.get returns the decoded Effect projection", async () => {
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
expect(result.executing).toBe(true)
})
test("session instructions methods use the public HTTP contract", async () => {
@@ -279,6 +280,7 @@ const session = {
},
title: "Test",
location: { directory: "/tmp/project" },
executing: true,
},
}
+2
View File
@@ -360,6 +360,7 @@ test("session.get returns the wire projection", async () => {
const result = await client.session.get({ sessionID: "ses_test" })
expect(result.time.created).toBe(1_717_171_717_000)
expect(result.executing).toBe(true)
})
test("session instructions methods use the public HTTP contract", async () => {
@@ -680,6 +681,7 @@ const session = {
},
title: "Test",
location: { directory: "/tmp/project" },
executing: true,
},
}
+6 -2
View File
@@ -5,7 +5,7 @@ import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/i
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Effect, Schema, Stream, Struct } from "effect"
import { Agent } from "../agent.js"
import { AISDK } from "../aisdk.js"
import { Catalog } from "../catalog.js"
@@ -394,7 +394,11 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
get: (input) => runtime.session.get(input.sessionID),
get: (input) =>
Effect.gen(function* () {
const info = yield* runtime.session.get(input.sessionID)
return Struct.assign(info, { executing: (yield* runtime.session.active).has(info.id) })
}),
switchAgent: runtime.session.switchAgent,
switchModel: runtime.session.switchModel,
prompt: runtime.session.prompt,
+2
View File
@@ -14,6 +14,7 @@ export interface Interface {
readonly session: Pick<
Session.Interface,
| "get"
| "active"
| "create"
| "messages"
| "prompt"
@@ -69,6 +70,7 @@ export const layerWithCell = (cell: Cell) =>
Service.of({
session: {
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
active: require(cell, (runtime) => runtime.session.active),
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
+2 -2
View File
@@ -229,13 +229,13 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Session.Info }),
success: Schema.Struct({ data: Session.GetInfo }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.get",
summary: "Get session",
description: "Retrieve a session by ID.",
description: "Retrieve a session by ID with its process-local runtime execution status.",
}),
),
)
+9
View File
@@ -52,6 +52,15 @@ export const Info = Schema.Struct({
revert: Revert.pipe(optional),
}).annotate({ identifier: "Session.Info" })
/** Session projection enriched with process-local runtime liveness. */
export interface GetInfo extends Schema.Schema.Type<typeof GetInfo> {}
export const GetInfo = Schema.Struct({
...Info.fields,
executing: Schema.Boolean.annotate({
description: "Whether this OpenCode process is currently executing the session.",
}),
}).annotate({ identifier: "Session.GetInfo" })
export const ListAnchor = Schema.Struct({
id: ID,
time: Schema.Finite,
+1
View File
@@ -301,6 +301,7 @@ it.live(
expect(created.id).toBe(id)
expect(selected.model?.id).toBe(model.id)
expect(selected.model?.providerID).toBe(model.providerID)
expect(selected.executing).toBe(false)
expect(page.data.some((session) => session.id === id)).toBe(true)
expect(active).toEqual({})
expect(admitted.sessionID).toBe(id)
+1
View File
@@ -43,6 +43,7 @@ test("Promise host uses the embedded router and releases plugins", async () => {
const events = Array.fromAsync(opencode.sessions.log({ sessionID: session.id }))
expect(selected.id).toBe(session.id)
expect(selected.executing).toBe(false)
expect(page.data.some((item) => item.id === session.id)).toBe(true)
expect((await events).some((event) => event.type === "session.created")).toBe(true)
expect(setup).toBe(true)
+5 -4
View File
@@ -2,7 +2,7 @@ import { Session } from "@opencode-ai/core/session"
import { SessionStats } from "@opencode-ai/core/session/stats"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect, Stream } from "effect"
import { DateTime, Effect, Stream, Struct } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
@@ -171,10 +171,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.get",
Effect.fn(function* (ctx) {
const info = yield* session
.get(ctx.params.sessionID)
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
return {
data: yield* session
.get(ctx.params.sessionID)
.pipe(Effect.catchTag("Session.NotFoundError", missingSession)),
data: Struct.assign(info, { executing: (yield* session.active).has(info.id) }),
}
}),
)
+49 -1
View File
@@ -1,9 +1,11 @@
import { expect } from "bun:test"
import { createServer, type Server } from "node:http"
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -253,6 +255,52 @@ it.live("serves the session view operation and missing-session error", () =>
}).pipe(Effect.scoped),
)
it.live("reports per-session runtime execution status", () =>
Effect.gen(function* () {
const executing = Session.ID.make("ses_executing")
const idle = Session.ID.make("ses_idle")
const handler = yield* ServerFetch.make(options, {
overrides: [
[
SessionExecution.node,
Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set([executing])),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
}),
),
],
],
})
yield* Effect.forEach([executing, idle], (id) =>
Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id }),
}),
),
),
)
const responses = yield* Effect.forEach([executing, idle], (id) =>
Effect.promise(() =>
handler(new Request(`http://opencode.local/api/session/${id}`)).then((response) => response.json()),
),
)
expect(responses).toMatchObject([
{ data: { id: executing, executing: true } },
{ data: { id: idle, executing: false } },
])
}).pipe(Effect.scoped),
)
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
@@ -190,6 +190,7 @@ describe("run session shared", () => {
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
executing: false,
}),
)