feat(sdk): restore session runtime operations (#33777)
This commit is contained in:
parent
44806777ca
commit
f44423609b
20 changed files with 1099 additions and 92 deletions
|
|
@ -1,5 +1,5 @@
|
|||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Stream, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
|
|
@ -143,6 +143,35 @@ const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11I
|
|||
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"]
|
||||
}
|
||||
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
|
||||
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 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) =>
|
||||
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"]
|
||||
}
|
||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint0_0(raw),
|
||||
create: Endpoint0_1(raw),
|
||||
|
|
@ -156,6 +185,9 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
|||
clear: Endpoint0_9(raw),
|
||||
commit: Endpoint0_10(raw),
|
||||
context: Endpoint0_11(raw),
|
||||
events: Endpoint0_12(raw),
|
||||
interrupt: Endpoint0_13(raw),
|
||||
message: Endpoint0_14(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ import type {
|
|||
SessionsCommitOutput,
|
||||
SessionsContextInput,
|
||||
SessionsContextOutput,
|
||||
SessionsEventsInput,
|
||||
SessionsEventsOutput,
|
||||
SessionsInterruptInput,
|
||||
SessionsInterruptOutput,
|
||||
SessionsMessageInput,
|
||||
SessionsMessageOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
|
|
@ -306,6 +312,40 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
||||
sse<SessionsEventsOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
|
||||
query: { after: input.after },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsInterruptOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
message: (input: SessionsMessageInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionsMessageOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -592,3 +592,619 @@ export type SessionsContextOutput = {
|
|||
}
|
||||
>
|
||||
}["data"]
|
||||
|
||||
export type SessionsEventsInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly after?: { readonly after?: string | undefined }["after"]
|
||||
}
|
||||
|
||||
export type SessionsEventsOutput =
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.agent.switched"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly agent: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.model.switched"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.moved"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.prompted"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.prompt.admitted"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.context.updated"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.synthetic"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.shell.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly callID: string
|
||||
readonly command: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.shell.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly callID: string
|
||||
readonly output: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.step.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly snapshot?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.step.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly finish: string
|
||||
readonly cost: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly snapshot?: string
|
||||
readonly files?: ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.step.failed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.text.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly textID: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.text.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly textID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.input.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly name: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.input.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.called"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly tool: string
|
||||
readonly input: { readonly [x: string]: unknown }
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.progress"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly structured: { readonly [x: string]: unknown }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.success"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly structured: { readonly [x: string]: unknown }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
readonly result?: unknown
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.failed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly result?: unknown
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.reasoning.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.reasoning.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.retried"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly attempt: number
|
||||
readonly error: {
|
||||
readonly message: string
|
||||
readonly statusCode?: number
|
||||
readonly isRetryable: boolean
|
||||
readonly responseHeaders?: { readonly [x: string]: string }
|
||||
readonly responseBody?: string
|
||||
readonly metadata?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.compaction.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.compaction.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.revert.staged"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly revert: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
readonly snapshot?: string
|
||||
readonly diff?: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly path: string
|
||||
readonly status: "added" | "modified" | "deleted"
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly patch: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.revert.cleared"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly timestamp: number; readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.revert.committed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string }
|
||||
}
|
||||
|
||||
export type SessionsInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsInterruptOutput = void
|
||||
|
||||
export type SessionsMessageInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
|
||||
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]
|
||||
}
|
||||
|
||||
export type SessionsMessageOutput = {
|
||||
readonly data:
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "model-switched"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly type: "user"
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly sessionID: string
|
||||
readonly text: string
|
||||
readonly type: "synthetic"
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "system"
|
||||
readonly text: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly type: "shell"
|
||||
readonly callID: string
|
||||
readonly command: string
|
||||
readonly output: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly type: "assistant"
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly id: string; readonly text: string }
|
||||
| {
|
||||
readonly type: "reasoning"
|
||||
readonly id: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly provider?: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
readonly state:
|
||||
| { readonly status: "pending"; readonly input: string }
|
||||
| {
|
||||
readonly status: "running"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly structured: { readonly [x: string]: JsonValue }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
}
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly attachments?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
readonly structured: { readonly [x: string]: JsonValue }
|
||||
readonly result?: JsonValue
|
||||
}
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly structured: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly result?: JsonValue
|
||||
}
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly ran?: number
|
||||
readonly completed?: number
|
||||
readonly pruned?: number
|
||||
}
|
||||
}
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: string
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
}
|
||||
}["data"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect"
|
||||
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
||||
|
||||
test("sessions.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
|
|
@ -18,12 +18,25 @@ test("sessions.get returns the decoded Effect projection", async () => {
|
|||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
if (url.includes("/event")) {
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.includes("/prompt")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||
}
|
||||
if (url.includes("/context")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
|
||||
}
|
||||
if (url.includes("/message/")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage })))
|
||||
}
|
||||
if (request.method === "POST" && url.endsWith("/api/session")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
|
||||
}
|
||||
|
|
@ -53,7 +66,15 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
||||
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
||||
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
||||
return { page, created, admitted, context }
|
||||
const events = yield* client.sessions
|
||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||
.pipe(Stream.runCollect)
|
||||
yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||
const message = yield* client.sessions.message({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
})
|
||||
return { page, 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)
|
||||
|
|
@ -64,6 +85,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
expect(result.context).toEqual([])
|
||||
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||
})
|
||||
|
||||
const session = {
|
||||
|
|
@ -96,3 +119,22 @@ const admission = {
|
|||
timeCreated: 1_717_171_717_000,
|
||||
},
|
||||
}
|
||||
|
||||
const modelSwitchedMessage = {
|
||||
id: "msg_model",
|
||||
type: "model-switched",
|
||||
time: { created: 1_717_171_717_000 },
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
}
|
||||
|
||||
const modelSwitchedEvent = {
|
||||
id: "evt_model",
|
||||
type: "session.next.model.switched",
|
||||
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||
data: {
|
||||
timestamp: 1_717_171_717_000,
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_model",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,14 @@ test("session methods use the public HTTP contract", async () => {
|
|||
fetch: async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
requests.push({ url, init })
|
||||
if (url.includes("/event")) {
|
||||
return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
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 (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" } })
|
||||
|
|
@ -47,11 +53,17 @@ test("session methods use the public HTTP contract", async () => {
|
|||
await client.sessions.compact({ sessionID: "ses_test" })
|
||||
await client.sessions.wait({ sessionID: "ses_test" })
|
||||
const context = await client.sessions.context({ sessionID: "ses_test" })
|
||||
const events = []
|
||||
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
|
||||
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(context).toEqual([])
|
||||
expect(events).toEqual([modelSwitchedEvent])
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
|
||||
["POST", "http://localhost:3000/api/session"],
|
||||
|
|
@ -61,6 +73,9 @@ test("session methods use the public HTTP contract", async () => {
|
|||
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||
["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
|
||||
if (typeof body !== "string") throw new Error("Expected JSON request body")
|
||||
|
|
@ -115,3 +130,22 @@ const admission = {
|
|||
timeCreated: 1_717_171_717_000,
|
||||
},
|
||||
}
|
||||
|
||||
const modelSwitchedMessage = {
|
||||
id: "msg_model",
|
||||
type: "model-switched",
|
||||
time: { created: 1_717_171_717_000 },
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
}
|
||||
|
||||
const modelSwitchedEvent = {
|
||||
id: "evt_model",
|
||||
type: "session.next.model.switched",
|
||||
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||
data: {
|
||||
timestamp: 1_717_171_717_000,
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_model",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,17 +70,28 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||
const sameModel =
|
||||
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
|
||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
if (item.type === "reasoning")
|
||||
return sameModel
|
||||
? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }]
|
||||
? [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: item.text,
|
||||
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
|
||||
},
|
||||
]
|
||||
: item.text.length > 0
|
||||
? [{ type: "text", text: item.text }]
|
||||
: []
|
||||
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
|
||||
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
|
||||
return item.provider?.executed === true && result ? [call, result] : [call]
|
||||
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
|
||||
if (item.provider?.executed !== true) return [call]
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
|
||||
)
|
||||
return result ? [call, result] : [call]
|
||||
})
|
||||
const meaningful = content.filter((part) => {
|
||||
if (part.type === "text") return part.text !== ""
|
||||
|
|
@ -89,7 +100,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
|||
})
|
||||
const results = message.content
|
||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
||||
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
|
||||
.map((item) =>
|
||||
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
|
||||
)
|
||||
.filter((message) => message !== undefined)
|
||||
.map(Message.tool)
|
||||
if (meaningful.length === 0) return results
|
||||
|
|
|
|||
|
|
@ -327,6 +327,78 @@ Recent work
|
|||
])
|
||||
})
|
||||
|
||||
test("drops provider-native continuation metadata from failed assistant turns", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-failed"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
id: "reasoning-failed",
|
||||
text: "Partial thought",
|
||||
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "call_failed" } },
|
||||
resultMetadata: { openai: { itemId: "result_failed" } },
|
||||
},
|
||||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: { query: "Effect" },
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
content: [],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "reasoning", text: "Partial thought", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-failed",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-failed",
|
||||
name: "web_search",
|
||||
result: {
|
||||
type: "error",
|
||||
value: {
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
content: [],
|
||||
structured: {},
|
||||
},
|
||||
},
|
||||
providerExecuted: true,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("drops provider-native continuation metadata after a model switch", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -37,6 +37,6 @@ The existing public `generate(Api, { directory })` operation writes the rich Eff
|
|||
|
||||
Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links.
|
||||
|
||||
Generated source starts with one self-contained module per `HttpApiGroup`, plus root client and index modules. Schema dependencies may be duplicated across group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it.
|
||||
Portable Effect output uses one self-contained module per `HttpApiGroup`, plus root client and index modules. Promise output uses shared type and client modules, while imported Effect output keeps adapters in the root client module. Schema dependencies may be duplicated across portable Effect group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it.
|
||||
|
||||
Codegen preserves group and endpoint identifiers exactly. The composed remote `HttpApi` owns public names such as `session` and `get`; the generator performs no prefix stripping, casing conversion, or public-name annotation mapping.
|
||||
Codegen preserves transport identifiers internally. `compile` may explicitly map consumer-facing group names, and endpoint operation IDs are projected to their final dot-delimited segment. The generator performs no other implicit product-specific naming or public-name annotation mapping.
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ type Slot = {
|
|||
|
||||
const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus")
|
||||
const resolveHttpApiEncoding = SchemaAST.resolveAt<HttpApiSchema.Encoding>("~httpApiEncoding")
|
||||
const resolveContentSchema = SchemaAST.resolveAt<SchemaAST.AST>("contentSchema")
|
||||
const Manifest = Schema.fromJsonString(Schema.Array(Schema.String))
|
||||
const manifestName = ".httpapi-codegen.json"
|
||||
|
||||
|
|
@ -125,9 +126,10 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
|
|||
...responseSchemas(success.schema, `${name}.success`),
|
||||
...errorSchemas.map((item) => [`${name}.error`, item.schema] as const),
|
||||
]
|
||||
const effectPortable = [params, query, headers, ...payloads, success, ...errorSchemas].every(
|
||||
(item) => item?.effectPortable !== false,
|
||||
)
|
||||
const effectPortable =
|
||||
[params, query, headers, ...payloads, success, ...errorSchemas].every(
|
||||
(item) => item?.effectPortable !== false,
|
||||
) && streamEffectPortable(success.schema)
|
||||
if (effectPortable) {
|
||||
for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable)
|
||||
}
|
||||
|
|
@ -454,7 +456,7 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
|||
const success = typeOf(
|
||||
isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
|
||||
? successSchema.sseMode === "data"
|
||||
? streamDataSchema(successSchema)
|
||||
? streamEncodedDataSchema(successSchema)
|
||||
: successSchema.events
|
||||
: successSchema,
|
||||
)
|
||||
|
|
@ -782,17 +784,6 @@ function responseSchemas(schema: Schema.Top, path: string): Array<readonly [stri
|
|||
if (!isStreamSchema(schema)) return [[path, schema]]
|
||||
if (schema._tag === "StreamUint8Array") return []
|
||||
const value = schema.sseMode === "data" ? streamDataSchema(schema) : schema.events
|
||||
const rebuilt =
|
||||
schema.sseMode === "data"
|
||||
? HttpApiSchema.StreamSse({ data: value, error: schema.error, contentType: schema.contentType })
|
||||
: HttpApiSchema.StreamSse({
|
||||
events: schema.events,
|
||||
error: schema.error,
|
||||
contentType: schema.contentType,
|
||||
})
|
||||
if (!sameEncoding(schema.events.ast, rebuilt.events.ast)) {
|
||||
throw new GenerationError({ reason: `Unportable schema: ${path}.${schema.sseMode}` })
|
||||
}
|
||||
return [
|
||||
[`${path}.${schema.sseMode}`, value],
|
||||
[`${path}.error`, schema.error],
|
||||
|
|
@ -964,11 +955,33 @@ function isStreamSchema(schema: Schema.Top): schema is HttpApiSchema.StreamSchem
|
|||
}
|
||||
|
||||
function streamDataSchema(schema: Extract<HttpApiSchema.StreamSchema, { readonly _tag: "StreamSse" }>) {
|
||||
const ast = Schema.toType(schema.events).ast
|
||||
return Schema.make(streamDataAst(Schema.toType(schema.events).ast))
|
||||
}
|
||||
|
||||
function streamEncodedDataSchema(schema: Extract<HttpApiSchema.StreamSchema, { readonly _tag: "StreamSse" }>) {
|
||||
const data = streamDataAst(schema.events.ast)
|
||||
const encodedAst = data.encoding?.at(-1)?.to
|
||||
if (encodedAst === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" })
|
||||
const encoded = resolveContentSchema(encodedAst)
|
||||
if (!SchemaAST.isAST(encoded)) throw new GenerationError({ reason: "Invalid SSE data schema" })
|
||||
return Schema.make(encoded)
|
||||
}
|
||||
|
||||
function streamDataAst(ast: SchemaAST.AST) {
|
||||
if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" })
|
||||
const data = ast.propertySignatures.find((field) => field.name === "data")?.type
|
||||
if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" })
|
||||
return Schema.make(data)
|
||||
return data
|
||||
}
|
||||
|
||||
function streamEffectPortable(schema: Schema.Top) {
|
||||
if (!isStreamSchema(schema) || schema._tag === "StreamUint8Array" || schema.sseMode === "events") return true
|
||||
const rebuilt = HttpApiSchema.StreamSse({
|
||||
data: streamDataSchema(schema),
|
||||
error: schema.error,
|
||||
contentType: schema.contentType,
|
||||
})
|
||||
return sameEncoding(schema.events.ast, rebuilt.events.ast)
|
||||
}
|
||||
|
||||
function renderGroup(group: Group, groupIndex: number) {
|
||||
|
|
|
|||
|
|
@ -395,7 +395,9 @@ describe("HttpApiCodegen.generate", () => {
|
|||
api(
|
||||
HttpApiEndpoint.get("subscribe", "/event", {
|
||||
query: { after: Schema.optional(Schema.Number) },
|
||||
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
|
||||
success: HttpApiSchema.StreamSse({
|
||||
data: Schema.Struct({ type: Schema.String, count: Schema.NumberFromString }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
@ -416,7 +418,7 @@ describe("HttpApiCodegen.generate", () => {
|
|||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"type":"ready"}\r'))
|
||||
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
|
||||
controller.enqueue(encoder.encode("\n\r\n"))
|
||||
controller.close()
|
||||
},
|
||||
|
|
@ -430,7 +432,7 @@ describe("HttpApiCodegen.generate", () => {
|
|||
expect(requests).toBe(0)
|
||||
const received = []
|
||||
for await (const event of events) received.push(event)
|
||||
expect(received).toEqual([{ type: "ready" }])
|
||||
expect(received).toEqual([{ type: "ready", count: "1" }])
|
||||
expect(requests).toBe(1)
|
||||
expect(url).toBe("https://example.com/event?after=2")
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1066,6 +1066,31 @@ const scenarios: Scenario[] = [
|
|||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(400, undefined, "none"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/event", "v2.session.events.missing")
|
||||
.at((ctx) => ({
|
||||
path: `${route("/api/session/{sessionID}/event", { sessionID: "ses_httpapi_missing" })}?after=0`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(404, undefined, "status"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/interrupt", "v2.session.interrupt")
|
||||
.seeded((ctx) => ctx.session({ title: "Interrupt session" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/interrupt", { sessionID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(204, undefined, "none"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/message/{messageID}", "v2.session.message.missing")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/message/{messageID}", {
|
||||
sessionID: "ses_httpapi_missing",
|
||||
messageID: "msg_httpapi_missing",
|
||||
}),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid")
|
||||
.seeded((ctx) => ctx.session({ title: "Invalid prompt owner" }))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { SessionInput } from "@opencode-ai/schema/session-input"
|
|||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Encoding, Result, Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
|
|
@ -20,6 +20,7 @@ import { Agent } from "@opencode-ai/schema/agent"
|
|||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
|
||||
const SessionsQueryFields = {
|
||||
workspace: Workspace.ID.pipe(Schema.optional),
|
||||
|
|
@ -272,6 +273,54 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: {
|
||||
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
|
||||
},
|
||||
success: HttpApiSchema.StreamSse({ data: SessionEvent.Durable }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.events",
|
||||
summary: "Subscribe to session events",
|
||||
description: "Replay durable events after an aggregate sequence, then continue with new durable events.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.interrupt",
|
||||
summary: "Interrupt session execution",
|
||||
description: "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", {
|
||||
params: { sessionID: Session.ID, messageID: SessionMessage.ID },
|
||||
success: Schema.Struct({ data: SessionMessage.Message }),
|
||||
error: [SessionNotFoundError, MessageNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.message",
|
||||
summary: "Get session message",
|
||||
description: "Retrieve one projected message owned by the Session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "sessions",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export type Payload<D extends Definition = Definition> = {
|
|||
|
||||
export function define<
|
||||
const Type extends string,
|
||||
Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
|
||||
const Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
|
||||
>(input: {
|
||||
readonly type: Type
|
||||
readonly durable?: {
|
||||
|
|
@ -49,23 +49,24 @@ export function define<
|
|||
readonly aggregate: string
|
||||
}
|
||||
readonly schema: Fields
|
||||
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
|
||||
}) {
|
||||
const data = Schema.Struct(input.schema)
|
||||
return Object.assign(
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
type: Schema.Literal(input.type),
|
||||
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
|
||||
location: optional(Location.Ref),
|
||||
data,
|
||||
}).annotate({ identifier: input.type }),
|
||||
{
|
||||
type: input.type,
|
||||
...(input.durable === undefined ? {} : { durable: input.durable }),
|
||||
data,
|
||||
},
|
||||
) as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>>
|
||||
return Schema.Struct({
|
||||
id: ID,
|
||||
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
type: Schema.Literal(input.type),
|
||||
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
|
||||
location: optional(Location.Ref),
|
||||
data,
|
||||
})
|
||||
.annotate({ identifier: input.type })
|
||||
.pipe(
|
||||
statics(() => ({
|
||||
type: input.type,
|
||||
...(input.durable === undefined ? {} : { durable: input.durable }),
|
||||
data,
|
||||
})),
|
||||
) satisfies Definition<Type, typeof data>
|
||||
}
|
||||
|
||||
export function inventory<const Definitions extends ReadonlyArray<Definition>>(...definitions: Definitions) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ const opencode = yield * OpenCode.create()
|
|||
const session = yield * opencode.sessions.get({ sessionID })
|
||||
```
|
||||
|
||||
It also exposes local-only `tools.register(...)`. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
|
||||
It also exports `Tool` and exposes local-only `tools.register(...)`, replacing the former `@opencode-ai/core/public` facade. Registration uses Core's host-level `ApplicationTools` service shared by the host's Locations; each Location retains its own `ToolRegistry` for overlay, lookup, and settlement. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
|
||||
|
||||
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
|
||||
|
||||
The same constructor is available as a service Layer:
|
||||
|
||||
|
|
|
|||
|
|
@ -2,43 +2,37 @@ import { OpenCode } from "@opencode-ai/client/effect"
|
|||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import {
|
||||
HttpClient,
|
||||
HttpRouter,
|
||||
HttpServer,
|
||||
HttpServerError,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
|
||||
export const create = Effect.fn("OpenCode.create")(function* () {
|
||||
const applicationTools = ApplicationTools.layer
|
||||
const { handler, permissions, tools } = yield* Effect.all({
|
||||
// Reusing this Layer value lets registration and every Location share one memoized host-level registry.
|
||||
handler: HttpRouter.toHttpEffect(
|
||||
createEmbeddedRoutes().pipe(Layer.provide(applicationTools), Layer.provide(HttpServer.layerServices)),
|
||||
),
|
||||
permissions: PermissionSaved.Service,
|
||||
tools: ApplicationTools.Service,
|
||||
}).pipe(Effect.provide(Layer.merge(applicationTools, PermissionSaved.defaultLayer)))
|
||||
const httpClient = HttpClient.make(
|
||||
Effect.fnUntraced(function* (request) {
|
||||
const response = yield* handler.pipe(
|
||||
Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)),
|
||||
Effect.provideService(ApplicationTools.Service, tools),
|
||||
Effect.provideService(PermissionSaved.Service, permissions),
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.interrupt
|
||||
: HttpServerError.causeResponse(cause).pipe(Effect.map(([response]) => response)),
|
||||
),
|
||||
)
|
||||
return HttpServerResponse.toClientResponse(response, { request })
|
||||
}, Effect.scoped),
|
||||
const scope = yield* Scope.Scope
|
||||
const memoMap = yield* Layer.makeMemoMap
|
||||
const context = yield* Layer.buildWithMemoMap(
|
||||
Layer.merge(ApplicationTools.layer, PermissionSaved.defaultLayer),
|
||||
memoMap,
|
||||
scope,
|
||||
)
|
||||
const tools = Context.get(context, ApplicationTools.Service)
|
||||
const permissions = Context.get(context, PermissionSaved.Service)
|
||||
const web = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
HttpRouter.toWebHandler(
|
||||
createEmbeddedRoutes().pipe(
|
||||
HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
{ disableLogger: true, memoMap },
|
||||
),
|
||||
),
|
||||
(web) => Effect.promise(web.dispose),
|
||||
)
|
||||
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), {
|
||||
preconnect: () => undefined,
|
||||
}) satisfies typeof globalThis.fetch
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, httpClient),
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.provideService(FetchHttpClient.Fetch, fetch),
|
||||
)
|
||||
return {
|
||||
...client,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"
|
|||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
|
||||
test("embedded client uses the real router and handlers", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
|
||||
|
|
@ -39,8 +39,31 @@ test("embedded client uses the real router and handlers", async () => {
|
|||
resume: false,
|
||||
})
|
||||
const context = yield* opencode.sessions.context({ sessionID })
|
||||
const missing = yield* Effect.flip(
|
||||
opencode.sessions.get({ sessionID: Session.ID.make(`ses_missing_${crypto.randomUUID()}`) }),
|
||||
const event = yield* opencode.sessions
|
||||
.events({ sessionID })
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined))
|
||||
const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
|
||||
Option.getOrThrow,
|
||||
)
|
||||
const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id })
|
||||
yield* opencode.sessions.interrupt({ sessionID })
|
||||
const other = yield* opencode.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`)
|
||||
const missing = yield* Effect.all(
|
||||
[
|
||||
opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
|
||||
opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
|
||||
opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const missingMessage = yield* Effect.flip(
|
||||
opencode.sessions.message({
|
||||
sessionID: other.id,
|
||||
messageID: modelMessage.id,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(created.id).toBe(sessionID)
|
||||
|
|
@ -49,7 +72,14 @@ test("embedded client uses the real router and handlers", async () => {
|
|||
expect(page.data.some((session) => session.id === sessionID)).toBe(true)
|
||||
expect(admitted.sessionID).toBe(sessionID)
|
||||
expect(context.some((message) => message.type === "model-switched")).toBe(true)
|
||||
expect(missing._tag).toBe("SessionNotFoundError")
|
||||
expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } })
|
||||
expect(message).toEqual(modelMessage)
|
||||
expect(missing.map((error) => error._tag)).toEqual([
|
||||
"SessionNotFoundError",
|
||||
"SessionNotFoundError",
|
||||
"SessionNotFoundError",
|
||||
])
|
||||
expect(missingMessage._tag).toBe("MessageNotFoundError")
|
||||
})
|
||||
await Effect.runPromise(Effect.scoped(program))
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
|
||||
|
|
@ -318,5 +318,32 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.events",
|
||||
Effect.fn((ctx) =>
|
||||
Effect.succeed(
|
||||
session.events({ sessionID: ctx.params.sessionID, after: ctx.query.after }).pipe(Stream.orDie),
|
||||
),
|
||||
),
|
||||
)
|
||||
.handle(
|
||||
"session.interrupt",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.interrupt(ctx.params.sessionID)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.message",
|
||||
Effect.fn(function* (ctx) {
|
||||
const message = yield* session.message(ctx.params)
|
||||
if (message) return { data: message }
|
||||
return yield* new MessageNotFoundError({
|
||||
sessionID: ctx.params.sessionID,
|
||||
messageID: ctx.params.messageID,
|
||||
message: `Message not found: ${ctx.params.messageID}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue