mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 15:06:21 +00:00
Compare commits
2
Commits
beta
...
async_webhooks
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b659ca45c | ||
|
|
02c5f32593 |
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -2143,6 +2143,9 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"callbackUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["text"],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user