Compare commits

...
2 Commits
Author SHA1 Message Date
Ryan Vogel 9b659ca45c test: trim redundant webhook assertions 2026-09-01 19:37:01 -04:00
Ryan Vogel 02c5f32593 feat(core): add async session webhooks 2026-09-01 19:24:54 -04:00
14 changed files with 369 additions and 6 deletions
+1
View File
@@ -266,6 +266,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
readonly callbackUrl?: string | undefined
}
export type SessionPromptOutput = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => Effect.Effect<SessionPromptOutput, E>
@@ -485,6 +485,7 @@ const EndpointSessionPrompt = (raw: RawClient["server.session"]) => (input: Sess
metadata: input["metadata"],
delivery: input["delivery"],
resume: input["resume"],
callbackUrl: input["callbackUrl"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -688,6 +688,7 @@ export function make(options: ClientOptions) {
metadata: input["metadata"],
delivery: input["delivery"],
resume: input["resume"],
callbackUrl: input["callbackUrl"],
},
successStatus: 200,
declaredStatuses: [409, 400, 404, 401],
@@ -3731,6 +3731,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["id"]
readonly text: {
readonly id?: string | null
@@ -3752,6 +3753,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["text"]
readonly files?: {
readonly id?: string | null
@@ -3773,6 +3775,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["files"]
readonly agents?: {
readonly id?: string | null
@@ -3794,6 +3797,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["agents"]
readonly skills?: {
readonly id?: string | null
@@ -3815,6 +3819,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["skills"]
readonly metadata?: {
readonly id?: string | null
@@ -3836,6 +3841,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["metadata"]
readonly delivery?: {
readonly id?: string | null
@@ -3857,6 +3863,7 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["delivery"]
readonly resume?: {
readonly id?: string | null
@@ -3878,7 +3885,30 @@ export type SessionPromptInput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["resume"]
readonly callbackUrl?: {
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
readonly callbackUrl?: string
}["callbackUrl"]
}
export type SessionPromptOutput = { data: SessionInboxUser }["data"]
+2
View File
@@ -804,6 +804,7 @@ test("session methods use the public HTTP contract", async () => {
sessionID: "ses_test",
text: "Hello",
resume: false,
callbackUrl: "https://notify.example.test/opencode",
})
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Summarize this session" })
const synthetic = await client.session.synthetic({
@@ -856,6 +857,7 @@ test("session methods use the public HTTP contract", async () => {
expect(JSON.parse(body)).toEqual({
text: "Hello",
resume: false,
callbackUrl: "https://notify.example.test/opencode",
})
const syntheticBody = requests.find((request) => request.url.endsWith("/synthetic"))?.init?.body
if (typeof syntheticBody !== "string") throw new Error("Expected JSON synthetic request body")
+2
View File
@@ -41,6 +41,7 @@ import {
SyntheticConflictError,
} from "./session/error.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { SessionInbox } from "./session/inbox.js"
@@ -525,6 +526,7 @@ export const node = makeGlobalNode({
service: Service,
layer,
deps: [
httpClient,
Job.node,
SessionEnvironment.node,
Database.node,
+4
View File
@@ -32,10 +32,12 @@ import { SessionPrompt } from "./prompt.js"
import { SessionRevert } from "./revert.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { SessionWebhook } from "./webhook.js"
type PromptRequest = SessionPrompt.Input & {
id?: SessionMessage.ID
resume?: boolean
callbackUrl?: string
}
/**
@@ -49,6 +51,7 @@ export const make = Effect.fn("Session.make")(function* () {
const execution = yield* SessionExecution.Service
const admission = yield* SessionInbox.Service
const scope = yield* Scope.Scope
const webhook = yield* SessionWebhook.make()
const get = Effect.fn("Session.get")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
@@ -181,6 +184,7 @@ export const make = Effect.fn("Session.make")(function* () {
}).pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () => new PromptConflictError({ sessionID, messageID })),
)
if (input.callbackUrl) yield* webhook(sessionID, input.callbackUrl)
if (input.resume !== false) yield* execution.wake(sessionID)
return admitted
}),
+6 -3
View File
@@ -41,6 +41,7 @@ export type ListInput = typeof ListInput.Type
export type MessagesInput = {
sessionID: Session.ID
type?: SessionMessage.Type
limit?: number
order?: "asc" | "desc"
cursor?: {
@@ -156,9 +157,11 @@ const layer = Layer.effect(
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const where = and(
eq(SessionMessageTable.session_id, input.sessionID),
input.type === undefined ? undefined : eq(SessionMessageTable.type, input.type),
boundary,
)
const query = db
.select()
.from(SessionMessageTable)
+77
View File
@@ -0,0 +1,77 @@
export * as SessionWebhook from "./webhook.js"
import { Effect, FiberMap, Schema, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Form } from "@opencode-ai/schema/form"
import { Permission } from "@opencode-ai/schema/permission"
import { Bus } from "../bus.js"
import { SessionEvent } from "./event.js"
import { SessionSchema } from "./schema.js"
import { SessionMessage } from "./message.js"
import { SessionStore } from "./store.js"
export const make = Effect.fn("SessionWebhook.make")(function* () {
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const http = HttpClient.withScope(HttpClient.filterStatusOk(yield* HttpClient.HttpClient))
// Capture the host context, not the submitting request's Location scope.
const run = yield* FiberMap.makeRuntime<never, SessionSchema.ID, never, void>()
return Effect.fn("SessionWebhook.subscribe")((sessionID: SessionSchema.ID, url: string) =>
Effect.sync(() => {
run(
sessionID,
bus
.subscribe([
SessionEvent.Execution.Started,
SessionEvent.Execution.Succeeded,
SessionEvent.Execution.Failed,
SessionEvent.Execution.Interrupted,
SessionEvent.Renamed,
SessionEvent.Deleted,
...Permission.Event.Definitions,
...Form.Event.Definitions,
])
.pipe(
Stream.filter(
(event) =>
(event.type === "form.created" ? event.data.form.sessionID : event.data.sessionID) === sessionID,
),
Stream.takeUntil(
(event) =>
event.type === "session.execution.succeeded" ||
event.type === "session.execution.failed" ||
event.type === "session.execution.interrupted" ||
event.type === "session.deleted",
),
Stream.runForEach((event) =>
Effect.gen(function* () {
const session = yield* store.get(sessionID)
const response = (yield* store.messages({ sessionID, type: "assistant", limit: 1 }))[0]
yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.bodyJson({
...event,
session: session ? Schema.encodeSync(SessionSchema.Info)(session) : null,
response: response ? Schema.encodeSync(SessionMessage.Info)(response) : null,
}),
Effect.flatMap(http.execute),
)
}).pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.scoped,
Effect.timeout("5 seconds"),
Effect.catch((error) =>
Effect.logWarning("Session webhook delivery failed", {
sessionID,
eventID: event.id,
eventType: event.type,
reason: error._tag,
}),
),
),
),
),
)
}),
)
})
+160 -1
View File
@@ -1,14 +1,18 @@
import { describe, expect } from "bun:test"
import { and, eq } from "drizzle-orm"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Queue, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Agent } from "@opencode-ai/schema/agent"
import { Event } from "@opencode-ai/schema/event"
import { Form } from "@opencode-ai/schema/form"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Permission } from "@opencode-ai/schema/permission"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { ID, Info, Output } from "@opencode-ai/schema/shell"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "../src/bus.js"
@@ -51,6 +55,7 @@ const it = testEffect(
SessionStore.node,
SessionInbox.node,
FSUtil.node,
httpClient,
]),
{
replacements: [Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer)],
@@ -166,6 +171,7 @@ const setup = Effect.fnUntraced(function* (options?: {
| Instance.Service
| SessionExecution.Service
| SessionInbox.Service
| HttpClient.HttpClient
| Scope.Scope
>(),
Effect.provideService(Instance.Service, {
@@ -287,6 +293,159 @@ describe("Session-owned handles", () => {
}),
)
it.live("forwards enriched session events across scopes and moves without blocking publication", () =>
Effect.gen(function* () {
const received = yield* Queue.unbounded<unknown>()
const response = Promise.withResolvers<void>()
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
expect(request.method).toBe("POST")
expect(request.headers.get("content-type")).toContain("application/json")
Queue.offerUnsafe(received, await request.json())
await response.promise
return new Response(null, { status: 204 })
},
}),
),
(server) => Effect.sync(() => response.resolve()).pipe(Effect.andThen(Effect.promise(() => server.stop(true)))),
)
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const callbackUrl = server.url.href
yield* handle
.prompt({ text: "Notify me", callbackUrl })
.pipe(Effect.provideService(Location.Service, location(source)), Effect.scoped)
expect(JSON.stringify(yield* handle.inbox())).not.toContain(callbackUrl)
const started = yield* fixture.bus.publish(SessionEvent.Execution.Started, { sessionID })
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toMatchObject({
...started,
response: null,
})
yield* fixture.bus.publish(SessionEvent.Moved, {
sessionID,
projectID: Project.ID.global,
location: Location.Ref.make({ directory: AbsolutePath.make("/project/moved") }),
subpath: RelativePath.make("moved"),
})
yield* fixture.bus.publish(SessionEvent.Renamed, { sessionID: otherID, title: "Unrelated" })
const responses = yield* Effect.forEach(["Earlier answer", "The review is ready."], (text) =>
Effect.gen(function* () {
const assistantMessageID = SessionMessage.ID.create()
yield* fixture.bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: Agent.ID.make("build"),
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
})
yield* fixture.bus.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, ordinal: 0 })
yield* fixture.bus.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, ordinal: 0, text })
yield* fixture.bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
return assistantMessageID
}),
)
yield* fixture.bus.publish(SessionEvent.Synthetic, { sessionID, text: "Trailing non-assistant message" })
const expected = [
yield* fixture.bus.publish(SessionEvent.Renamed, { sessionID, title: "Working" }),
yield* fixture.bus.publish(Permission.Event.Asked, {
sessionID,
id: Permission.ID.create(),
action: "shell",
resources: ["pwd"],
}),
yield* fixture.bus.publish(Form.Event.Created, {
form: Form.Info.make({
id: Form.ID.create(),
sessionID,
title: "Continue?",
fields: [{ key: "continue", type: "boolean" }],
}),
}),
yield* fixture.bus.publish(SessionEvent.Execution.Succeeded, { sessionID }),
]
// Session work settles while the receiver still has not acknowledged the first POST.
expect((yield* handle.get()).outcome).toBe("succeeded")
response.resolve()
const delivered = yield* Effect.forEach(expected, () => Queue.take(received)).pipe(Effect.timeout("2 seconds"))
expect(delivered).toMatchObject(expected)
expect(delivered.at(-1)).toMatchObject({
session: {
id: sessionID,
title: "Working",
outcome: "succeeded",
location: { directory: "/project/moved" },
time: { created: expect.any(Number), updated: expect.any(Number), idle: expect.any(Number) },
},
response: {
id: responses.at(-1),
type: "assistant",
content: [{ type: "text", text: "The review is ready." }],
time: { created: expect.any(Number), completed: expect.any(Number) },
},
})
yield* handle.rename({ title: "After completion" })
expect(Option.isNone(yield* Queue.take(received).pipe(Effect.timeoutOption("50 millis")))).toBe(true)
}),
)
it.live("replaces callbacks and isolates HTTP failures without following redirects", () =>
Effect.gen(function* () {
const received = yield* Queue.unbounded<{ path: string; body: unknown }>()
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const path = new URL(request.url).pathname
Queue.offerUnsafe(received, { path, body: await request.json() })
return new Response(null, {
status: path === "/redirect" ? 307 : 503,
headers: { location: new URL("/unexpected", request.url).href },
})
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const prompt = yield* handle.prompt({ text: "First", resume: false, callbackUrl: `${server.url}old` })
yield* handle.prompt({ id: prompt.id, text: "Retry", resume: false, callbackUrl: `${server.url}failed` })
const started = yield* fixture.bus.publish(SessionEvent.Execution.Started, { sessionID })
const failed = yield* fixture.bus.publish(SessionEvent.Execution.Failed, {
sessionID,
error: { type: "unknown", message: "Run failed" },
})
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toMatchObject({
path: "/failed",
body: started,
})
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toMatchObject({
path: "/failed",
body: failed,
})
yield* handle.prompt({ text: "Redirect", resume: false, callbackUrl: `${server.url}redirect` })
const deleted = yield* fixture.bus.publish(SessionEvent.Deleted, { sessionID })
expect(yield* Queue.take(received).pipe(Effect.timeout("2 seconds"))).toEqual({
path: "/redirect",
body: { ...deleted, session: null, response: null },
})
expect(Option.isNone(yield* Queue.take(received).pipe(Effect.timeoutOption("50 millis")))).toBe(true)
}),
)
it.live("keeps the first admission across handles, including delivered retries and identity conflicts", () =>
Effect.gen(function* () {
const fixture = yield* setup()
+3
View File
@@ -2143,6 +2143,9 @@
"type": "null"
}
]
},
"callbackUrl": {
"type": "string"
}
},
"required": ["text"],
+5 -1
View File
@@ -6,7 +6,7 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionStats } from "@opencode-ai/schema/session-stats"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, optional, statics } from "@opencode-ai/schema/schema"
import { Event } from "@opencode-ai/schema/event"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
@@ -343,6 +343,10 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
metadata: SessionInbox.UserPayload.fields.metadata,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
callbackUrl: Schema.String.pipe(optional).annotate({
description:
"HTTP(S) URL for best-effort JSON POSTs of execution, rename, permission and form events until execution settles or the session is deleted. Each event includes session and response fields containing the current Session.Info and latest assistant message in HTTP API JSON format, or null when unavailable. Replaces the active callback for this session. Process-local, without retries; the URL is not stored in prompt history.",
}),
}),
success: Schema.Struct({ data: SessionInbox.User }),
error: [ConflictError, InvalidRequestError, SessionNotFoundError],
+9
View File
@@ -289,6 +289,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.prompt",
Effect.fn(function* (ctx) {
if (ctx.payload.callbackUrl !== undefined) {
const url = URL.parse(ctx.payload.callbackUrl)
if (!url || !["http:", "https:"].includes(url.protocol) || url.username || url.password)
return yield* new InvalidRequestError({
message: "Expected an HTTP(S) callback URL without credentials",
field: "callbackUrl",
})
}
return {
data: yield* session
.prompt({
@@ -301,6 +309,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
metadata: ctx.payload.metadata,
delivery: ctx.payload.delivery,
resume: ctx.payload.resume,
callbackUrl: ctx.payload.callbackUrl,
})
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
+68 -1
View File
@@ -5,7 +5,7 @@ import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
import { Effect, Schedule, Schema } from "effect"
import { Effect, Queue, Schedule, Schema } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -118,6 +118,73 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
}),
)
it.live("validates prompt callback URLs and delivers real execution events over HTTP", () =>
Effect.gen(function* () {
const received = yield* Queue.unbounded<unknown>()
const receiver = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
Queue.offerUnsafe(received, await request.json())
return new Response(null, { status: 204 })
},
}),
),
(server) => Effect.promise(() => server.stop(true)),
)
const handler = yield* ServerFetch.make(options)
const created = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: { id: "missing-model", providerID: "missing-provider" } }),
}),
).then((response) => response.json()),
)
const endpoint = `http://opencode.local/api/session/${created.data.id}`
yield* Effect.forEach(["not a URL", "ftp://example.test", "https://user:password@example.test"], (callbackUrl) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() =>
handler(
new Request(`${endpoint}/prompt`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "Invalid callback", callbackUrl }),
}),
),
)
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "InvalidRequestError",
field: "callbackUrl",
})
}),
)
const submitted = yield* Effect.promise(() =>
handler(
new Request(`${endpoint}/prompt`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "Notify me", callbackUrl: receiver.url.href }),
}),
),
)
expect(submitted.status).toBe(200)
expect(yield* Queue.take(received).pipe(Effect.timeout("5 seconds"))).toMatchObject({
type: "session.execution.started",
data: { sessionID: created.data.id },
})
expect(yield* Queue.take(received).pipe(Effect.timeout("5 seconds"))).toMatchObject({
type: "session.execution.failed",
data: { sessionID: created.data.id },
session: { id: created.data.id, outcome: "failed" },
})
}),
)
it.live("activates credentials through the HttpApi", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)