feat(core): session.pending.list API with pending-only session_pending storage (#36126)

This commit is contained in:
Kit Langton 2026-07-09 17:25:40 -04:00 committed by GitHub
commit a72992e00f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 986 additions and 649 deletions

View file

@ -226,64 +226,69 @@ export type Endpoint5_20Input = { readonly sessionID: Endpoint5_20Request["param
export type Endpoint5_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
export type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
export type Endpoint5_21Output = EffectValue<
export type Endpoint5_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.pending.list"]>>["data"]
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
export type Endpoint5_22Input = { readonly sessionID: Endpoint5_22Request["params"]["sessionID"] }
export type Endpoint5_22Output = EffectValue<
ReturnType<RawClient["server.session"]["session.instructions.entry.list"]>
>["data"]
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_21Input,
) => Effect.Effect<Endpoint5_21Output, E>
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
export type Endpoint5_22Input = {
readonly sessionID: Endpoint5_22Request["params"]["sessionID"]
readonly key: Endpoint5_22Request["params"]["key"]
readonly value: Endpoint5_22Request["payload"]["value"]
}
export type Endpoint5_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_22Input,
) => Effect.Effect<Endpoint5_22Output, E>
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
export type Endpoint5_23Input = {
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
readonly key: Endpoint5_23Request["params"]["key"]
readonly value: Endpoint5_23Request["payload"]["value"]
}
export type Endpoint5_23Output = EffectValue<
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
>
export type SessionInstructionsEntryRemoveOperation<E = never> = (
export type Endpoint5_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_23Input,
) => Effect.Effect<Endpoint5_23Output, E>
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
export type Endpoint5_24Input = {
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
readonly after?: Endpoint5_24Request["query"]["after"]
readonly follow?: Endpoint5_24Request["query"]["follow"]
readonly key: Endpoint5_24Request["params"]["key"]
}
export type Endpoint5_24Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint5_24Input) => Stream.Stream<Endpoint5_24Output, E>
export type Endpoint5_24Output = EffectValue<
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
>
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint5_25Input = { readonly sessionID: Endpoint5_25Request["params"]["sessionID"] }
export type Endpoint5_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
export type Endpoint5_25Input = {
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
readonly after?: Endpoint5_25Request["query"]["after"]
readonly follow?: Endpoint5_25Request["query"]["follow"]
}
export type Endpoint5_25Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint5_25Input) => Stream.Stream<Endpoint5_25Output, E>
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
export type Endpoint5_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint5_27Input = {
readonly sessionID: Endpoint5_27Request["params"]["sessionID"]
readonly messageID: Endpoint5_27Request["params"]["messageID"]
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint5_28Input = {
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
readonly messageID: Endpoint5_28Request["params"]["messageID"]
}
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@ -309,6 +314,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly instructions: {
readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E>

View file

@ -318,43 +318,51 @@ const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20I
Effect.map((value) => value.data),
)
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
type Endpoint5_21Request = Parameters<RawClient["server.session"]["session.pending.list"]>[0]
type Endpoint5_21Input = { readonly sessionID: Endpoint5_21Request["params"]["sessionID"] }
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
type Endpoint5_22Input = { readonly sessionID: Endpoint5_22Request["params"]["sessionID"] }
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint5_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
type Endpoint5_22Input = {
readonly sessionID: Endpoint5_22Request["params"]["sessionID"]
readonly key: Endpoint5_22Request["params"]["key"]
readonly value: Endpoint5_22Request["payload"]["value"]
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
type Endpoint5_23Input = {
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
readonly key: Endpoint5_23Request["params"]["key"]
readonly value: Endpoint5_23Request["payload"]["value"]
}
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint5_23Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
type Endpoint5_23Input = {
readonly sessionID: Endpoint5_23Request["params"]["sessionID"]
readonly key: Endpoint5_23Request["params"]["key"]
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
type Endpoint5_24Input = {
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
readonly key: Endpoint5_24Request["params"]["key"]
}
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint5_24Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_24Input = {
readonly sessionID: Endpoint5_24Request["params"]["sessionID"]
readonly after?: Endpoint5_24Request["query"]["after"]
readonly follow?: Endpoint5_24Request["query"]["follow"]
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_25Input = {
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
readonly after?: Endpoint5_25Request["query"]["after"]
readonly follow?: Endpoint5_25Request["query"]["follow"]
}
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@ -365,22 +373,22 @@ const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24I
),
)
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint5_25Input = { readonly sessionID: Endpoint5_25Request["params"]["sessionID"] }
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint5_27Input = {
readonly sessionID: Endpoint5_27Request["params"]["sessionID"]
readonly messageID: Endpoint5_27Request["params"]["messageID"]
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint5_28Input = {
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
readonly messageID: Endpoint5_28Request["params"]["messageID"]
}
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@ -406,11 +414,12 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
instructions: { entry: { list: Endpoint5_21(raw), put: Endpoint5_22(raw), remove: Endpoint5_23(raw) } },
log: Endpoint5_24(raw),
interrupt: Endpoint5_25(raw),
background: Endpoint5_26(raw),
message: Endpoint5_27(raw),
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
log: Endpoint5_25(raw),
interrupt: Endpoint5_26(raw),
background: Endpoint5_27(raw),
message: Endpoint5_28(raw),
})
type Endpoint6_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]

View file

@ -38,7 +38,7 @@ export { Question } from "@opencode-ai/schema/question"
export { Reference } from "@opencode-ai/schema/reference"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInput } from "@opencode-ai/schema/session-input"
export { SessionPending } from "@opencode-ai/schema/session-pending"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"

View file

@ -48,6 +48,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionPendingListInput,
SessionPendingListOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@ -666,6 +668,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
pending: {
list: (input: SessionPendingListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionPendingListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
instructions: {
entry: {
list: (input: SessionInstructionsEntryListInput, requestOptions?: RequestOptions) =>

View file

@ -624,7 +624,6 @@ export type SessionPromptOutput = {
id: string
sessionID: string
timeCreated: number
promotedSeq?: number
type: "user"
data: {
text: string
@ -824,7 +823,6 @@ export type SessionCommandOutput = {
id: string
sessionID: string
timeCreated: number
promotedSeq?: number
type: "user"
data: {
text: string
@ -922,7 +920,6 @@ export type SessionSyntheticOutput = {
id: string
sessionID: string
timeCreated: number
promotedSeq?: number
type: "synthetic"
data: { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
delivery: "steer" | "queue"
@ -943,14 +940,7 @@ export type SessionCompactInput = {
}
export type SessionCompactOutput = {
data: {
admittedSeq: number
id: string
sessionID: string
timeCreated: number
type: "compaction"
handledSeq?: number
}
data: { admittedSeq: number; id: string; sessionID: string; timeCreated: number; type: "compaction" }
}["data"]
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@ -1145,6 +1135,44 @@ export type SessionContextOutput = {
>
}["data"]
export type SessionPendingListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionPendingListOutput = {
data: Array<
| {
admittedSeq: number
id: string
sessionID: string
timeCreated: number
type: "user"
data: {
text: string
files?: Array<{
data: string
mime: string
source: { type: "inline" } | { type: "uri"; uri: string }
name?: string
description?: string
mention?: { start: number; end: number; text: string }
}>
agents?: Array<{ name: string; mention?: { start: number; end: number; text: string } }>
metadata?: { [x: string]: JsonValue }
}
delivery: "steer" | "queue"
}
| {
admittedSeq: number
id: string
sessionID: string
timeCreated: number
type: "synthetic"
data: { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
delivery: "steer" | "queue"
}
| { admittedSeq: number; id: string; sessionID: string; timeCreated: number; type: "compaction" }
>
}["data"]
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListOutput = { data: Array<{ key: string; value: JsonValue }> }["data"]

View file

@ -1,25 +1,10 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Location as CoreLocation } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { ClientApi, groupNames, promiseOmitEndpoints } from "../src/contract"
const Client = await import("../src/effect")
@ -29,34 +14,6 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Session).toBe(Session)
})
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID)
expect(CoreLocation.Ref).toBe(Location.Ref)
expect(ModelV2.Ref).toBe(Model.Ref)
expect(SessionV2.Info).toBe(Session.Info)
expect(ProjectV2.Current).toBe(Project.Current)
expect(ProjectV2.Directory).toBe(Project.Directory)
expect(ProjectV2.Directories).toBe(Project.Directories)
expect(CoreSessionInput.Message).toBe(SessionInput.Message)
expect(CoreSessionInput.User).toBe(SessionInput.User)
expect(CoreSessionInput.Synthetic).toBe(SessionInput.Synthetic)
expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
expect(Api.groups["server.session"].identifier).toBe("server.session")
expect(Api.groups["server.project"].identifier).toBe("server.project")
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
expect(Session.ID.create()).toStartWith("ses_")
expect(Project.ID.global).toBe("global")
expect(Provider.ID.anthropic).toBe("anthropic")
expect(Workspace.ID.create()).toStartWith("wrk_")
})
test("client and Server contracts generate identically", () => {
const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
expect(emitPromise(client)).toEqual(emitPromise(server))
})
test("shared DTO schemas construct and decode plain objects", () => {
const made = Prompt.make({ text: "hello" })
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
@ -67,5 +24,4 @@ test("shared DTO schemas construct and decode plain objects", () => {
expect(Object.getPrototypeOf(content)).toBe(Object.prototype)
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
})

View file

@ -227,6 +227,34 @@ test("session instructions methods use the public HTTP contract", async () => {
])
})
test("session.pending.list uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const pending = [
{
admittedSeq: 3,
id: "msg_pending",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
type: "user",
data: { text: "Fix the failing tests" },
delivery: "steer",
},
]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return Response.json({ data: pending })
},
})
const result = await client.session.pending.list({ sessionID: "ses_test" })
expect(result).toEqual(pending)
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",