mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 02:56:18 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
114d5c9dff | ||
|
|
983877952a | ||
|
|
a9042a58ab | ||
|
|
244ec6c8f7 | ||
|
|
e28471e0ad | ||
|
|
778d5b675c | ||
|
|
127113188e | ||
|
|
ce16b7cc12 | ||
|
|
eda6d774bf | ||
|
|
e11b3d08b6 | ||
|
|
0cdd711abf | ||
|
|
22c63833d2 | ||
|
|
42d160f4a0 | ||
|
|
8be467de8d | ||
|
|
50c5218bca | ||
|
|
c1763e2b64 | ||
|
|
34bd7c220c | ||
|
|
7f5ea1889c |
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -125,6 +125,7 @@ const GeminiContentPart = Schema.Union([
|
||||
GeminiFunctionCallPart,
|
||||
GeminiFunctionResponsePart,
|
||||
])
|
||||
const decodeGeminiContentPart = Schema.decodeUnknownOption(GeminiContentPart)
|
||||
|
||||
const GeminiContent = Schema.Struct({
|
||||
role: optionalNull(Schema.Literals(["user", "model"])),
|
||||
@@ -132,6 +133,11 @@ const GeminiContent = Schema.Struct({
|
||||
})
|
||||
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
|
||||
|
||||
const GeminiResponseContent = Schema.Struct({
|
||||
role: optionalNull(Schema.Literals(["user", "model"])),
|
||||
parts: optionalNull(Schema.Array(Schema.Unknown)),
|
||||
})
|
||||
|
||||
const GeminiSystemInstruction = Schema.Struct({
|
||||
parts: Schema.Array(Schema.Struct({ text: Schema.String })),
|
||||
})
|
||||
@@ -200,7 +206,7 @@ const GeminiUsage = Schema.Struct({
|
||||
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
|
||||
|
||||
const GeminiCandidate = Schema.Struct({
|
||||
content: optionalNull(GeminiContent),
|
||||
content: optionalNull(GeminiResponseContent),
|
||||
finishReason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
@@ -222,6 +228,7 @@ const GeminiEvent = Schema.Struct({
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly route: string
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
@@ -598,7 +605,21 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||
const seenCallIds = new Set(nextState.seenCallIds)
|
||||
|
||||
for (const part of candidate.content.parts ?? []) {
|
||||
for (const input of candidate.content.parts ?? []) {
|
||||
if (
|
||||
ProviderShared.isRecord(input) &&
|
||||
!("text" in input) &&
|
||||
!("inlineData" in input) &&
|
||||
!("functionCall" in input) &&
|
||||
!("functionResponse" in input)
|
||||
)
|
||||
continue
|
||||
const decoded = decodeGeminiContentPart(input)
|
||||
if (Option.isNone(decoded))
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(ADAPTER, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
|
||||
)
|
||||
const part = decoded.value
|
||||
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
|
||||
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
|
||||
// each block kind must retain the signature attached to its own parts.
|
||||
@@ -691,7 +712,11 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(GeminiEvent),
|
||||
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
|
||||
initial: (request) => ({
|
||||
route: `${request.model.provider}/${request.model.route.id}`,
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
step,
|
||||
onHalt: finish,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Adapted from partial-json by the Promplate Dev Team:
|
||||
* https://github.com/promplate/partial-json-parser-js/blob/main/src/options.ts
|
||||
* Licensed under the MIT License; see partial-json.ts for the complete notice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* allow partial strings like `"hello \u12` to be parsed as `"hello `
|
||||
*/
|
||||
export const STR = 0b000000001
|
||||
|
||||
/**
|
||||
* allow partial numbers like `123.` to be parsed as `123`
|
||||
*/
|
||||
export const NUM = 0b000000010
|
||||
|
||||
/**
|
||||
* allow partial arrays like `[1, 2,` to be parsed as `[1, 2]`
|
||||
*/
|
||||
export const ARR = 0b000000100
|
||||
|
||||
/**
|
||||
* allow partial objects like `{"a": 1, "b":` to be parsed as `{"a": 1}`
|
||||
*/
|
||||
export const OBJ = 0b000001000
|
||||
|
||||
/**
|
||||
* allow `nu` to be parsed as `null`
|
||||
*/
|
||||
export const NULL = 0b000010000
|
||||
|
||||
/**
|
||||
* allow `tr` to be parsed as `true`, and `fa` to be parsed as `false`
|
||||
*/
|
||||
export const BOOL = 0b000100000
|
||||
|
||||
/**
|
||||
* allow `Na` to be parsed as `NaN`
|
||||
*/
|
||||
export const NAN = 0b001000000
|
||||
|
||||
/**
|
||||
* allow `Inf` to be parsed as `Infinity`
|
||||
*/
|
||||
export const INFINITY = 0b010000000
|
||||
|
||||
/**
|
||||
* allow `-Inf` to be parsed as `-Infinity`
|
||||
*/
|
||||
export const _INFINITY = 0b100000000
|
||||
|
||||
export const INF = INFINITY | _INFINITY
|
||||
export const SPECIAL = NULL | BOOL | INF | NAN
|
||||
export const ATOM = STR | NUM | SPECIAL
|
||||
export const COLLECTION = ARR | OBJ
|
||||
export const ALL = ATOM | COLLECTION
|
||||
|
||||
/**
|
||||
* Control what types you allow to be partially parsed.
|
||||
* The default is to allow all types to be partially parsed, which in most cases is the best option.
|
||||
*/
|
||||
export const Allow = { STR, NUM, ARR, OBJ, NULL, BOOL, NAN, INFINITY, _INFINITY, INF, SPECIAL, ATOM, COLLECTION, ALL }
|
||||
|
||||
export default Allow
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Adapted from partial-json by the Promplate Dev Team:
|
||||
* https://github.com/promplate/partial-json-parser-js
|
||||
*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2023 Promplate Dev Team
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Allow } from "./partial-json-options.js"
|
||||
export * from "./partial-json-options.js"
|
||||
|
||||
export class PartialJSON extends Error {}
|
||||
export class MalformedJSON extends Error {}
|
||||
|
||||
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
/** Parse complete or incomplete JSON, restricted by the supplied partial-value flags. */
|
||||
export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown {
|
||||
if (typeof jsonString !== "string") throw new TypeError(`expecting str, got ${typeof jsonString}`)
|
||||
const input = jsonString.trim()
|
||||
if (!input) throw new Error(`${jsonString} is empty`)
|
||||
try {
|
||||
return decodeJson(input)
|
||||
} catch {}
|
||||
return _parseJSON(input, allowPartial)
|
||||
}
|
||||
|
||||
const _parseJSON = (jsonString: string, allow: number) => {
|
||||
const length = jsonString.length
|
||||
let index = 0
|
||||
|
||||
const markPartialJSON = (message: string): never => {
|
||||
throw new PartialJSON(`${message} at position ${index}`)
|
||||
}
|
||||
|
||||
const throwMalformedError = (message: string): never => {
|
||||
throw new MalformedJSON(`${message} at position ${index}`)
|
||||
}
|
||||
|
||||
const parseAny = (): unknown => {
|
||||
skipBlank()
|
||||
if (index >= length) markPartialJSON("Unexpected end of input")
|
||||
if (jsonString[index] === '"') return parseStr()
|
||||
if (jsonString[index] === "{") return parseObj()
|
||||
if (jsonString[index] === "[") return parseArr()
|
||||
if (
|
||||
jsonString.substring(index, index + 4) === "null" ||
|
||||
(Allow.NULL & allow && length - index < 4 && "null".startsWith(jsonString.substring(index)))
|
||||
) {
|
||||
index += 4
|
||||
return null
|
||||
}
|
||||
if (
|
||||
jsonString.substring(index, index + 4) === "true" ||
|
||||
(Allow.BOOL & allow && length - index < 4 && "true".startsWith(jsonString.substring(index)))
|
||||
) {
|
||||
index += 4
|
||||
return true
|
||||
}
|
||||
if (
|
||||
jsonString.substring(index, index + 5) === "false" ||
|
||||
(Allow.BOOL & allow && length - index < 5 && "false".startsWith(jsonString.substring(index)))
|
||||
) {
|
||||
index += 5
|
||||
return false
|
||||
}
|
||||
if (
|
||||
jsonString.substring(index, index + 8) === "Infinity" ||
|
||||
(Allow.INFINITY & allow && length - index < 8 && "Infinity".startsWith(jsonString.substring(index)))
|
||||
) {
|
||||
index += 8
|
||||
return Infinity
|
||||
}
|
||||
if (
|
||||
jsonString.substring(index, index + 9) === "-Infinity" ||
|
||||
(Allow._INFINITY & allow &&
|
||||
1 < length - index &&
|
||||
length - index < 9 &&
|
||||
"-Infinity".startsWith(jsonString.substring(index)))
|
||||
) {
|
||||
index += 9
|
||||
return -Infinity
|
||||
}
|
||||
if (
|
||||
jsonString.substring(index, index + 3) === "NaN" ||
|
||||
(Allow.NAN & allow && length - index < 3 && "NaN".startsWith(jsonString.substring(index)))
|
||||
) {
|
||||
index += 3
|
||||
return NaN
|
||||
}
|
||||
return parseNum()
|
||||
}
|
||||
|
||||
const parseStr = (): string => {
|
||||
const start = index
|
||||
let escape = false
|
||||
index++
|
||||
while (index < length && (jsonString[index] !== '"' || (escape && jsonString[index - 1] === "\\"))) {
|
||||
escape = jsonString[index] === "\\" ? !escape : false
|
||||
index++
|
||||
}
|
||||
if (jsonString.charAt(index) === '"') {
|
||||
try {
|
||||
return decodeJson(jsonString.substring(start, ++index - Number(escape))) as string
|
||||
} catch (error) {
|
||||
throwMalformedError(String(error))
|
||||
}
|
||||
}
|
||||
if (Allow.STR & allow) {
|
||||
try {
|
||||
return decodeJson(`${jsonString.substring(start, index - Number(escape))}"`) as string
|
||||
} catch {
|
||||
return decodeJson(`${jsonString.substring(start, jsonString.lastIndexOf("\\"))}"`) as string
|
||||
}
|
||||
}
|
||||
return markPartialJSON("Unterminated string literal")
|
||||
}
|
||||
|
||||
const parseObj = (): Record<string, unknown> => {
|
||||
index++
|
||||
skipBlank()
|
||||
const object: Record<string, unknown> = {}
|
||||
try {
|
||||
while (jsonString[index] !== "}") {
|
||||
skipBlank()
|
||||
if (index >= length && Allow.OBJ & allow) return object
|
||||
const key = parseStr()
|
||||
skipBlank()
|
||||
index++
|
||||
try {
|
||||
object[key] = parseAny()
|
||||
} catch (error) {
|
||||
if (Allow.OBJ & allow) return object
|
||||
throw error
|
||||
}
|
||||
skipBlank()
|
||||
if (jsonString[index] === ",") index++
|
||||
}
|
||||
} catch {
|
||||
if (Allow.OBJ & allow) return object
|
||||
return markPartialJSON("Expected '}' at end of object")
|
||||
}
|
||||
index++
|
||||
return object
|
||||
}
|
||||
|
||||
const parseArr = (): unknown[] => {
|
||||
index++
|
||||
const array: unknown[] = []
|
||||
try {
|
||||
while (jsonString[index] !== "]") {
|
||||
array.push(parseAny())
|
||||
skipBlank()
|
||||
if (jsonString[index] === ",") index++
|
||||
}
|
||||
} catch {
|
||||
if (Allow.ARR & allow) return array
|
||||
return markPartialJSON("Expected ']' at end of array")
|
||||
}
|
||||
index++
|
||||
return array
|
||||
}
|
||||
|
||||
const parseNum = (): unknown => {
|
||||
if (index === 0) {
|
||||
if (jsonString === "-") throwMalformedError("Not sure what '-' is")
|
||||
try {
|
||||
return decodeJson(jsonString)
|
||||
} catch (error) {
|
||||
if (Allow.NUM & allow) {
|
||||
try {
|
||||
return decodeJson(jsonString.substring(0, jsonString.lastIndexOf("e")))
|
||||
} catch {}
|
||||
}
|
||||
throwMalformedError(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const start = index
|
||||
if (jsonString[index] === "-") index++
|
||||
while (jsonString[index] && !",]}".includes(jsonString[index])) index++
|
||||
if (index === length && !(Allow.NUM & allow)) markPartialJSON("Unterminated number literal")
|
||||
|
||||
try {
|
||||
return decodeJson(jsonString.substring(start, index))
|
||||
} catch (error) {
|
||||
if (jsonString.substring(start, index) === "-") markPartialJSON("Not sure what '-' is")
|
||||
try {
|
||||
return decodeJson(jsonString.substring(start, jsonString.lastIndexOf("e")))
|
||||
} catch {
|
||||
throwMalformedError(String(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const skipBlank = () => {
|
||||
while (index < length && " \n\r\t".includes(jsonString[index])) index++
|
||||
}
|
||||
|
||||
return parseAny()
|
||||
}
|
||||
|
||||
export const parse = parseJSON
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Allow, MalformedJSON, PartialJSON, parse } from "../src/protocols/utils/partial-json.js"
|
||||
|
||||
describe("partial JSON", () => {
|
||||
test("parses complete JSON", () => {
|
||||
expect(parse('{"key":"value","items":[1,true,null]}')).toEqual({
|
||||
key: "value",
|
||||
items: [1, true, null],
|
||||
})
|
||||
|
||||
const object = parse('{"__proto__":{"safe":true}}') as Record<string, unknown>
|
||||
expect(Object.hasOwn(object, "__proto__")).toBe(true)
|
||||
})
|
||||
|
||||
test("parses partial strings", () => {
|
||||
expect(parse('"hello')).toBe("hello")
|
||||
expect(parse('"hello \\u12')).toBe("hello ")
|
||||
expect(() => parse('"hello', ~Allow.STR)).toThrow(PartialJSON)
|
||||
})
|
||||
|
||||
test("controls partial collection values independently", () => {
|
||||
expect(parse('["', Allow.ARR)).toEqual([])
|
||||
expect(parse('["', Allow.ARR | Allow.STR)).toEqual([""])
|
||||
expect(parse('{"key":"', Allow.OBJ)).toEqual({})
|
||||
expect(parse('{"key":"', Allow.OBJ | Allow.STR)).toEqual({ key: "" })
|
||||
})
|
||||
|
||||
test("parses partial literals and numbers", () => {
|
||||
expect(parse("nu", Allow.NULL)).toBeNull()
|
||||
expect(parse("tr", Allow.BOOL)).toBe(true)
|
||||
expect(parse("fa", Allow.BOOL)).toBe(false)
|
||||
expect(parse("1e", Allow.NUM)).toBe(1)
|
||||
})
|
||||
|
||||
test("distinguishes disallowed partial values from malformed values", () => {
|
||||
expect(() => parse("[", Allow.STR)).toThrow(PartialJSON)
|
||||
expect(() => parse("n", ~Allow.NULL)).toThrow(MalformedJSON)
|
||||
})
|
||||
|
||||
test("rejects empty input", () => {
|
||||
expect(() => parse(" ")).toThrow("is empty")
|
||||
})
|
||||
})
|
||||
@@ -906,6 +906,54 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown response parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "Hello " },
|
||||
{ executableCode: { language: "PYTHON", code: "print('ignored')" } },
|
||||
{ text: "world" },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello world")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed recognized response parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [{ content: { role: "model", parts: [{ text: 42 }] } }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(error.message).toContain("Invalid google/gemini stream event")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
|
||||
@@ -37,7 +37,11 @@ export function createActiveComposerAdapter(input: {
|
||||
current: () => data.session.get(id),
|
||||
admitted: (messageID) => data.session.input.has(id, messageID) || !!data.session.message.get(id, messageID),
|
||||
}),
|
||||
interrupt: () => server.api.session.interrupt({ sessionID: id, continue: true }).catch(() => undefined),
|
||||
interrupt: () =>
|
||||
server.api.session
|
||||
.interrupt({ sessionID: id, continue: true })
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined),
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
|
||||
@@ -601,6 +601,7 @@ describe("acp event behavior", () => {
|
||||
},
|
||||
onInterrupt({ sessionID, send }) {
|
||||
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
|
||||
return true
|
||||
},
|
||||
})
|
||||
const result = streamTurn({
|
||||
@@ -624,7 +625,7 @@ describe("acp event behavior", () => {
|
||||
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
|
||||
expect(await fixture.client.session.interrupt({ sessionID: "ses_cancel" })).toEqual({ interrupted: true })
|
||||
|
||||
const response = await withTimeout(result, "cancelled turn did not terminate")
|
||||
expect(response).toMatchObject({ stopReason: "cancelled" })
|
||||
|
||||
@@ -12,13 +12,7 @@ describe("acp service prompt routing and usage", () => {
|
||||
return Response.json({ data: makeSession("ses_routes") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
|
||||
const id = requestID(request)
|
||||
completeTurn(context, "ses_routes", {
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_routes", inboxID: id },
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
|
||||
const id = requestID(request)
|
||||
@@ -65,9 +59,8 @@ describe("acp service prompt routing and usage", () => {
|
||||
const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
|
||||
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
|
||||
expect(command?.body).toMatchObject({
|
||||
id: expect.any(String),
|
||||
command: "review",
|
||||
arguments: "now",
|
||||
text: "now",
|
||||
files: [],
|
||||
delivery: "steer",
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ type FixtureOptions = {
|
||||
readonly onInterrupt?: (input: {
|
||||
readonly sessionID: string
|
||||
readonly send: (event: unknown) => void
|
||||
}) => void | Promise<void>
|
||||
}) => boolean | Promise<boolean>
|
||||
readonly onPermissionReply?: (input: {
|
||||
readonly sessionID: string
|
||||
readonly requestID: string
|
||||
@@ -152,8 +152,9 @@ export function createSseFixture(options: FixtureOptions = {}) {
|
||||
|
||||
const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
|
||||
if (interrupt?.[1]) {
|
||||
await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
|
||||
return new Response(null, { status: 204 })
|
||||
const interrupted =
|
||||
(await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })) ?? false
|
||||
return Response.json({ interrupted })
|
||||
}
|
||||
|
||||
return new Response(null, { status: 404 })
|
||||
|
||||
@@ -998,7 +998,7 @@ export type SessionLogOutput =
|
||||
export type SessionLogOperation<E = never> = (input: SessionLogInput) => Stream.Stream<SessionLogOutput, E>
|
||||
|
||||
export type SessionInterruptInput = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type SessionInterruptOutput = void
|
||||
export type SessionInterruptOutput = { readonly interrupted: boolean }
|
||||
export type SessionInterruptOperation<E = never> = (
|
||||
input: SessionInterruptInput,
|
||||
) => Effect.Effect<SessionInterruptOutput, E>
|
||||
@@ -1108,11 +1108,7 @@ export interface ModelApi<E = never> {
|
||||
readonly default: ModelDefaultOperation<E>
|
||||
}
|
||||
|
||||
export type GenerateTextInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly prompt: string
|
||||
readonly model?: Model.Ref | undefined
|
||||
}
|
||||
export type GenerateTextInput = { readonly prompt: string; readonly model?: Model.Ref | undefined }
|
||||
export type GenerateTextOutput = { readonly text: string }
|
||||
export type GenerateTextOperation<E = never> = (input: GenerateTextInput) => Effect.Effect<GenerateTextOutput, E>
|
||||
|
||||
@@ -1695,6 +1691,12 @@ export interface WorktreeApi<E = never> {
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type WorkspaceCreateInput = { readonly id?: Workspace.ID | undefined; readonly provider: string }
|
||||
export type WorkspaceCreateOutput = Workspace.ID
|
||||
export type WorkspaceCreateOperation<E = never> = (
|
||||
input: WorkspaceCreateInput,
|
||||
) => Effect.Effect<WorkspaceCreateOutput, E>
|
||||
|
||||
export type WorkspaceDestroyInput = { readonly workspaceID: Workspace.ID }
|
||||
export type WorkspaceDestroyOutput = Workspace.DestroyResult
|
||||
export type WorkspaceDestroyOperation<E = never> = (
|
||||
@@ -1702,6 +1704,7 @@ export type WorkspaceDestroyOperation<E = never> = (
|
||||
) => Effect.Effect<WorkspaceDestroyOutput, E>
|
||||
|
||||
export interface WorkspaceApi<E = never> {
|
||||
readonly create: WorkspaceCreateOperation<E>
|
||||
readonly destroy: WorkspaceDestroyOperation<E>
|
||||
}
|
||||
|
||||
|
||||
@@ -214,6 +214,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
WorkspaceCreateInput,
|
||||
WorkspaceCreateOutput,
|
||||
WorkspaceDestroyInput,
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
@@ -719,10 +721,7 @@ const adaptGroupModel = (raw: RawClient["server.model"]) => ({
|
||||
|
||||
const EndpointGenerateText = (raw: RawClient["server.generate"]) => (input: GenerateTextInput) =>
|
||||
preserveEffect<GenerateTextOutput>()(
|
||||
raw["generate.text"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { prompt: input["prompt"], model: input["model"] },
|
||||
}).pipe(
|
||||
raw["generate.text"]({ payload: { prompt: input["prompt"], model: input["model"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -1273,12 +1272,23 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
|
||||
refresh: EndpointWorktreeRefresh(raw),
|
||||
})
|
||||
|
||||
const EndpointWorkspaceCreate = (raw: RawClient["server.workspace"]) => (input: WorkspaceCreateInput) =>
|
||||
preserveEffect<WorkspaceCreateOutput>()(
|
||||
raw["workspace.create"]({ payload: { id: input["id"], provider: input["provider"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointWorkspaceDestroy = (raw: RawClient["server.workspace"]) => (input: WorkspaceDestroyInput) =>
|
||||
preserveEffect<WorkspaceDestroyOutput>()(
|
||||
raw["workspace.destroy"]({ params: { workspaceID: input["workspaceID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupWorkspace = (raw: RawClient["server.workspace"]) => ({ destroy: EndpointWorkspaceDestroy(raw) })
|
||||
const adaptGroupWorkspace = (raw: RawClient["server.workspace"]) => ({
|
||||
create: EndpointWorkspaceCreate(raw),
|
||||
destroy: EndpointWorkspaceDestroy(raw),
|
||||
})
|
||||
|
||||
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
|
||||
preserveEffect<VcsGetOutput>()(
|
||||
|
||||
@@ -210,6 +210,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
WorkspaceCreateInput,
|
||||
WorkspaceCreateOutput,
|
||||
WorkspaceDestroyInput,
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
@@ -878,9 +880,9 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
|
||||
query: { continue: input["continue"] },
|
||||
successStatus: 204,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
@@ -977,7 +979,6 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/generate`,
|
||||
query: { location: input["location"] },
|
||||
body: { prompt: input["prompt"], model: input["model"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
@@ -1769,6 +1770,18 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
workspace: {
|
||||
create: (input: WorkspaceCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: WorkspaceCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/workspace`,
|
||||
body: { id: input["id"], provider: input["provider"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
destroy: (input: WorkspaceDestroyInput, requestOptions?: RequestOptions) =>
|
||||
request<WorkspaceDestroyOutput>(
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,7 +172,14 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
request.url.includes("/interrupt")
|
||||
? Response.json({ interrupted: true })
|
||||
: new Response(null, { status: 204 }),
|
||||
),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
|
||||
@@ -202,12 +209,12 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const log = yield* client.session
|
||||
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
|
||||
.pipe(Stream.runCollect)
|
||||
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||
const interrupted = yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||
const message = yield* client.session.message({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
})
|
||||
return { page, active, created, admitted, context, log, message }
|
||||
return { page, active, created, admitted, context, log, interrupted, message }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
const listed = result.page.data[0]
|
||||
@@ -216,6 +223,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
|
||||
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
|
||||
expect(result.active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(result.interrupted).toEqual({ interrupted: true })
|
||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||
expect(result.created.id).toBe("ses_test")
|
||||
|
||||
@@ -82,6 +82,21 @@ test("config.get returns ordered config entries for a location", async () => {
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("generate.text uses the locationless public contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ data: { text: "pong" } })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.generate.text({ prompt: "ping" })).toEqual({ text: "pong" })
|
||||
expect(request?.url).toBe("http://localhost:3000/api/generate")
|
||||
expect(await request?.json()).toEqual({ prompt: "ping" })
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
@@ -532,6 +547,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
|
||||
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
|
||||
if (url.includes("/interrupt")) return Response.json({ interrupted: true })
|
||||
if (init?.method === "POST") return new Response(null, { status: 204 })
|
||||
return Response.json({ data: [session.data], cursor: { next: "next" } })
|
||||
},
|
||||
@@ -563,7 +579,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const context = await client.session.context({ sessionID: "ses_test" })
|
||||
const log = []
|
||||
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
|
||||
await client.session.interrupt({ sessionID: "ses_test", continue: true })
|
||||
const interrupted = await client.session.interrupt({ sessionID: "ses_test", continue: true })
|
||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
@@ -572,6 +588,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(generated.text).toBe("A transient answer")
|
||||
expect(interrupted).toEqual({ interrupted: true })
|
||||
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
|
||||
expect(context).toEqual([])
|
||||
expect(log).toEqual([modelSwitchedEvent, synced])
|
||||
|
||||
@@ -402,7 +402,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
command: runtime.session.command,
|
||||
rename: runtime.session.rename,
|
||||
synthetic: runtime.session.synthetic,
|
||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||
interrupt: (input) =>
|
||||
runtime.session
|
||||
.interrupt(input.sessionID, { continue: input.continue })
|
||||
.pipe(Effect.map((interrupted) => ({ interrupted }))),
|
||||
wait: (input) => runtime.session.wait(input.sessionID),
|
||||
},
|
||||
} satisfies Plugin.Context
|
||||
|
||||
@@ -236,6 +236,7 @@ const pre = [
|
||||
MCPCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
CommandPlugin.Plugin,
|
||||
SkillPlugin.Plugin,
|
||||
...SystemPromptPlugin.Plugins,
|
||||
@@ -274,7 +275,6 @@ const post = [
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
export const list = Effect.fn("PluginInternal.list")(function* () {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Server } from "node:http"
|
||||
import { App } from "../../app.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
@@ -12,6 +13,9 @@ import type { PluginInternal } from "../internal.js"
|
||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const issuer = "https://auth.openai.com"
|
||||
const callbackPort = 1455
|
||||
const callbackFallbackPort = 1457
|
||||
const callbackBindAttempts = 10
|
||||
const callbackBindRetryDelay = 200
|
||||
const pollingSafetyMargin = 3000
|
||||
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
|
||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||
@@ -55,11 +59,10 @@ const browser = (app: App.Info) =>
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const redirect = `http://localhost:${callbackPort}/auth/callback`
|
||||
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
|
||||
const { createServer } = yield* Effect.promise(() => import("node:http"))
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
|
||||
const url = new URL(request.url ?? "/", "http://localhost")
|
||||
if (url.pathname !== "/auth/callback") {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
@@ -86,11 +89,9 @@ const browser = (app: App.Info) =>
|
||||
.writeHead(200, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(callbackPort, "localhost", () => resume(Effect.void))
|
||||
})
|
||||
const port = yield* listen(server)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
const redirect = `http://localhost:${port}/auth/callback`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: authorizeURL(redirect, pkce, state),
|
||||
@@ -104,6 +105,66 @@ const browser = (app: App.Info) =>
|
||||
refresh: (value) => refresh(browserMethodID, value, app),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
function listen(server: Server) {
|
||||
return bind(server, callbackPort).pipe(
|
||||
Effect.as(callbackPort),
|
||||
Effect.catchIf(addressInUse, () =>
|
||||
cancel(callbackPort).pipe(
|
||||
Effect.ignore,
|
||||
Effect.andThen(Effect.sleep(callbackBindRetryDelay)),
|
||||
Effect.andThen(bindWithRetry(server, callbackPort, callbackBindAttempts - 1)),
|
||||
Effect.as(callbackPort),
|
||||
Effect.catchIf(addressInUse, () =>
|
||||
bindWithRetry(server, callbackFallbackPort, callbackBindAttempts).pipe(
|
||||
Effect.as(callbackFallbackPort),
|
||||
Effect.catchIf(addressInUse, () =>
|
||||
Effect.fail(
|
||||
new Error(
|
||||
`OpenAI browser login needs local port ${callbackPort} or ${callbackFallbackPort}, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.`,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function bindWithRetry(server: Server, port: number, attempts: number): Effect.Effect<void, Error> {
|
||||
return bind(server, port).pipe(
|
||||
Effect.catchIf(
|
||||
(error) => addressInUse(error) && attempts > 1,
|
||||
() => Effect.sleep(callbackBindRetryDelay).pipe(Effect.andThen(bindWithRetry(server, port, attempts - 1))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function bind(server: Server, port: number) {
|
||||
return Effect.callback<void, Error>((resume) => {
|
||||
const onError = (error: Error) => resume(Effect.fail(error))
|
||||
server.once("error", onError)
|
||||
server.listen(port, "localhost", () => {
|
||||
server.off("error", onError)
|
||||
resume(Effect.void)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function cancel(port: number) {
|
||||
return Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
fetch(`http://localhost:${port}/cancel`, {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]),
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function addressInUse(error: Error) {
|
||||
return "code" in error && error.code === "EADDRINUSE"
|
||||
}
|
||||
|
||||
const headless = (app: App.Info) =>
|
||||
({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
|
||||
@@ -146,8 +146,10 @@ bug.
|
||||
|
||||
For questions about creating, configuring, loading, publishing, or migrating
|
||||
plugins, fetch the full [plugins guide](https://opencode.ai/v2/docs/build/plugins)
|
||||
before answering. This includes questions about the Effect plugin API, hooks,
|
||||
transforms, tools, plugin context capabilities, and package entrypoints.
|
||||
before answering. Refer to this guide when the user wants to build a plugin. It
|
||||
covers hooks, transforms, tools, plugin context capabilities, and package
|
||||
entrypoints. Plugins can also extend the TUI; for those, fetch the
|
||||
[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli).
|
||||
|
||||
## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service)
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ export interface Interface {
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
|
||||
readonly synthetic: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
|
||||
@@ -24,9 +24,10 @@ export interface Interface {
|
||||
/**
|
||||
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
|
||||
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
|
||||
* Compose with `awaitIdle` when settlement matters.
|
||||
* Returns whether an active execution was interrupted. Compose with `awaitIdle` when
|
||||
* settlement matters.
|
||||
*/
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -140,8 +141,8 @@ export const layer = Layer.effect(
|
||||
active: coordinator.active,
|
||||
interrupt: (sessionID, options) =>
|
||||
Effect.gen(function* () {
|
||||
yield* coordinator.interrupt(sessionID, "user")
|
||||
if (!options?.continue) return
|
||||
const interrupted = yield* coordinator.interrupt(sessionID, "user")
|
||||
if (!options?.continue) return interrupted
|
||||
// Resume steering input and between-turn control work from the interrupted
|
||||
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
|
||||
// promotes them, and a control item behind a queued prompt waits its turn.
|
||||
@@ -151,9 +152,10 @@ export const layer = Layer.effect(
|
||||
// rows inside uninterruptible publications, so a steer row is either still
|
||||
// promotable here or was fully delivered and needs no resumption.
|
||||
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
if (next === undefined) return
|
||||
if (next === undefined) return interrupted
|
||||
if (next.delivery === "steer" || next.type === "compaction" || next.type === "move")
|
||||
yield* coordinator.wake(sessionID, "steer")
|
||||
return interrupted
|
||||
}),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
@@ -175,7 +177,7 @@ export const noopLayer = Layer.succeed(
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -14,9 +14,10 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
/**
|
||||
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
|
||||
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
|
||||
* finalizers and settled hook on its own time. Compose with `awaitIdle` for settlement.
|
||||
* finalizers and settled hook on its own time. Returns whether an active execution was
|
||||
* interrupted. Compose with `awaitIdle` for settlement.
|
||||
*/
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
@@ -134,16 +135,16 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
start(key, false, scope)
|
||||
})
|
||||
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<boolean> =>
|
||||
Effect.sync(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined || execution.stopping) return Effect.void
|
||||
if (execution === undefined || execution.stopping) return false
|
||||
if (execution.owner === undefined) {
|
||||
// Settlement window: the owner exited but the settled hook has not finished. The
|
||||
// terminal outcome is already decided, so no reason attaches — but the interrupt
|
||||
// still claims the recorded wakes so settle does not start a dead-intent successor.
|
||||
execution.pendingWake = undefined
|
||||
return Effect.void
|
||||
return false
|
||||
}
|
||||
execution.stopping = true
|
||||
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
|
||||
@@ -153,7 +154,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
// Fire and forget: nobody benefits from waiting out cleanup here, and callers like
|
||||
// the interrupt endpoint must acknowledge immediately even when finalizers are slow.
|
||||
fork(Fiber.interrupt(execution.owner))
|
||||
return Effect.void
|
||||
return true
|
||||
})
|
||||
|
||||
// One execution's `done` already spans coalesced continuations; re-check after it
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { ToolDefinition } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
|
||||
|
||||
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
|
||||
|
||||
const jsonSchemas = Effect.runSync(
|
||||
Cache.make<JsonSchema.JsonSchema, Schema.Codec<unknown> | undefined>({
|
||||
capacity: 100,
|
||||
lookup: (schema) =>
|
||||
Effect.try({
|
||||
try: () => jsonSchema(schema),
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.orElseSucceed(() => undefined)),
|
||||
}),
|
||||
)
|
||||
|
||||
export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
|
||||
name: effectiveName(tool),
|
||||
@@ -12,7 +25,7 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
|
||||
|
||||
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* decodeInput(tool.input, input)
|
||||
const decoded = yield* decodeInput(tool, input)
|
||||
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
|
||||
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
|
||||
// downstream and leave its call permanently unsettled, so the declared contract is
|
||||
@@ -44,13 +57,51 @@ export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool
|
||||
}
|
||||
})
|
||||
|
||||
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
if (Schema.isSchema(schema))
|
||||
return Schema.decodeUnknownEffect(schema)(value).pipe(
|
||||
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
|
||||
const decodeInput = (tool: Tool.Info<any, any>, value: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* validateInput(tool.input, value)
|
||||
if (result.issues)
|
||||
return yield* new Tool.Error({ message: formatInputIssues(effectiveName(tool), result.issues, value) })
|
||||
return result.value
|
||||
})
|
||||
|
||||
const validateInput = (
|
||||
schema: Tool.ValueSchema<any>,
|
||||
value: unknown,
|
||||
): Effect.Effect<StandardSchemaV1.Result<unknown>> => {
|
||||
if (isStandardSchema(schema)) return validateStandard(schema, value)
|
||||
return Effect.gen(function* () {
|
||||
const codec = Schema.isSchema(schema) ? schema : yield* Cache.get(jsonSchemas, schema)
|
||||
if (codec === undefined) return { value }
|
||||
return yield* Schema.decodeUnknownEffect(codec)(value, { errors: "all" }).pipe(
|
||||
Effect.match({
|
||||
onFailure: (error) => formatEffectIssues(error.issue),
|
||||
onSuccess: (value) => ({ value }),
|
||||
}),
|
||||
)
|
||||
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
|
||||
return Effect.succeed(value)
|
||||
})
|
||||
}
|
||||
|
||||
const formatInputIssues = (tool: string, issues: ReadonlyArray<StandardSchemaV1.Issue>, value: unknown) => {
|
||||
const details = issues.slice(0, 5).map((issue) => {
|
||||
const path =
|
||||
issue.path?.reduce<string>((path, segment) => {
|
||||
const key = typeof segment === "object" ? segment.key : segment
|
||||
if (typeof key === "number") return `${path}[${key}]`
|
||||
return path === "" ? String(key) : `${path}.${String(key)}`
|
||||
}, "") || "root"
|
||||
return `- ${path}: ${issue.message}`
|
||||
})
|
||||
if (issues.length > 5) details.push(`- ...and ${issues.length - 5} more ${issues.length === 6 ? "issue" : "issues"}`)
|
||||
return `Invalid arguments for tool "${tool}":\n${details.join("\n")}\n\nArguments provided:\n${JSON.stringify(value, null, 2)}\n\nUpdate the arguments and call the tool again.`
|
||||
}
|
||||
|
||||
const jsonSchema = (schema: JsonSchema.JsonSchema) => {
|
||||
const draft =
|
||||
(typeof schema.$schema === "string" && schema.$schema.includes("draft-07")) || "definitions" in schema
|
||||
? JsonSchema.fromSchemaDraft07(schema)
|
||||
: JsonSchema.fromSchemaDraft2020_12(schema)
|
||||
return Schema.make<Schema.Codec<unknown>>(SchemaRepresentation.fromJsonSchemaDocument(draft).ast)
|
||||
}
|
||||
|
||||
const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
@@ -62,7 +113,15 @@ const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
),
|
||||
)
|
||||
if (isStandardSchema(schema))
|
||||
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
|
||||
return validateStandard(schema, value).pipe(
|
||||
Effect.flatMap((result) =>
|
||||
result.issues
|
||||
? new Tool.Error({
|
||||
message: `Tool returned an invalid value for its output schema: ${result.issues.map((issue) => issue.message).join(", ")}`,
|
||||
})
|
||||
: Effect.succeed(result.value),
|
||||
),
|
||||
)
|
||||
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new Tool.Error({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }),
|
||||
@@ -78,26 +137,16 @@ const isStandardSchema = (
|
||||
const validateStandard = (
|
||||
schema: StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>,
|
||||
value: unknown,
|
||||
prefix: string,
|
||||
) =>
|
||||
): Effect.Effect<StandardSchemaV1.Result<unknown>> =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* Effect.try({
|
||||
try: () => schema["~standard"].validate(value),
|
||||
catch: (error) => standardFailure(prefix, error),
|
||||
})
|
||||
const result =
|
||||
pending instanceof Promise
|
||||
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
|
||||
: pending
|
||||
if (result.issues)
|
||||
return yield* new Tool.Error({
|
||||
message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}`,
|
||||
})
|
||||
return result.value
|
||||
})
|
||||
|
||||
const standardFailure = (prefix: string, error: unknown) =>
|
||||
new Tool.Error({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
|
||||
const result = yield* Effect.try({ try: () => schema["~standard"].validate(value), catch: (error) => error })
|
||||
return result instanceof Promise ? yield* Effect.tryPromise({ try: () => result, catch: (error) => error }) : result
|
||||
}).pipe(
|
||||
Effect.match({
|
||||
onFailure: (error) => ({ issues: [{ message: error instanceof Error ? error.message : String(error) }] }),
|
||||
onSuccess: (result) => result,
|
||||
}),
|
||||
)
|
||||
|
||||
const inputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
|
||||
if (schema === undefined || schema === null) return {}
|
||||
|
||||
@@ -25,9 +25,18 @@ export class Info extends Schema.Class<Info>("Workspace.Info")({
|
||||
|
||||
export class NotFound extends Schema.TaggedError<NotFound>()("Workspace.NotFound", { workspaceID: ID }) {}
|
||||
|
||||
export class CreateConflict extends Schema.TaggedError<CreateConflict>()("Workspace.CreateConflict", {
|
||||
workspaceID: ID,
|
||||
provider: Schema.String,
|
||||
existingProvider: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
/** Instantly commits a logical workspace ID. No provider work happens here. */
|
||||
readonly create: (provider: string) => Effect.Effect<ID, WorkspaceDriver.ProviderNotFound>
|
||||
readonly create: (input: {
|
||||
readonly id?: ID
|
||||
readonly provider: string
|
||||
}) => Effect.Effect<ID, CreateConflict | WorkspaceDriver.ProviderNotFound>
|
||||
/** Starts or joins the shared attempt that makes the backing resource real, then returns it. */
|
||||
readonly provision: (
|
||||
workspaceID: ID,
|
||||
@@ -212,15 +221,39 @@ const layer = (options: Options) =>
|
||||
}).pipe(Effect.repeat(Schedule.spaced(options.pollInterval ?? Duration.minutes(1))), Effect.forkScoped)
|
||||
|
||||
return Service.of({
|
||||
create: Effect.fn("Workspace.create")(function* (provider) {
|
||||
yield* registry.get(provider)
|
||||
const workspaceID = ID.create()
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
yield* db
|
||||
.insert(WorkspaceTable)
|
||||
.values({ id: workspaceID, provider, binding: null, created_at: now, last_used_at: now })
|
||||
.run()
|
||||
create: Effect.fn("Workspace.create")(function* (input) {
|
||||
const workspaceID = input.id ?? ID.create()
|
||||
const existing = yield* db
|
||||
.select({ provider: WorkspaceTable.provider })
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (existing) {
|
||||
if (existing.provider === input.provider) return workspaceID
|
||||
return yield* new CreateConflict({
|
||||
workspaceID,
|
||||
provider: input.provider,
|
||||
existingProvider: existing.provider,
|
||||
})
|
||||
}
|
||||
yield* registry.get(input.provider)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const inserted = yield* db
|
||||
.insert(WorkspaceTable)
|
||||
.values({ id: workspaceID, provider: input.provider, binding: null, created_at: now, last_used_at: now })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: WorkspaceTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (inserted) return workspaceID
|
||||
const row = yield* load(workspaceID).pipe(Effect.orDie)
|
||||
if (row.provider !== input.provider)
|
||||
return yield* new CreateConflict({
|
||||
workspaceID,
|
||||
provider: input.provider,
|
||||
existingProvider: row.provider,
|
||||
})
|
||||
return workspaceID
|
||||
}),
|
||||
provision,
|
||||
|
||||
@@ -52,6 +52,17 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows the built-in Plan agent to be disabled", () =>
|
||||
withLocation(
|
||||
{ agents: { plan: { disabled: true } } },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("plan"))).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads configured Promise plugins with options", () =>
|
||||
withLocation(
|
||||
{
|
||||
|
||||
@@ -14,16 +14,22 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const it = testEffect(
|
||||
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves no-content and rejected Promise behavior", () =>
|
||||
it.effect("preserves interrupt results and rejected Promise behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: unknown[] = []
|
||||
const host = testHost({
|
||||
@@ -131,7 +131,7 @@ describe("fromPromise", () => {
|
||||
return Effect.fail(new Error("interrupt failed"))
|
||||
}
|
||||
expect(input.continue).toBe(true)
|
||||
return Effect.void
|
||||
return Effect.succeed({ interrupted: false })
|
||||
},
|
||||
switchAgent: (input) => Effect.sync(() => seen.push(input)),
|
||||
switchModel: (input) => Effect.sync(() => seen.push(input)),
|
||||
@@ -144,7 +144,9 @@ describe("fromPromise", () => {
|
||||
define({
|
||||
id: "promise-session-interrupt",
|
||||
setup: async (ctx) => {
|
||||
expect(await ctx.session.interrupt({ sessionID: "ses_success", continue: true })).toBeUndefined()
|
||||
expect(await ctx.session.interrupt({ sessionID: "ses_success", continue: true })).toEqual({
|
||||
interrupted: false,
|
||||
})
|
||||
await expect(ctx.session.interrupt({ sessionID: "ses_failure" })).rejects.toThrow("interrupt failed")
|
||||
expect(await ctx.session.switchAgent({ sessionID: "ses_success", agent: "build" })).toBeUndefined()
|
||||
expect(
|
||||
|
||||
@@ -128,12 +128,25 @@ describe("SessionExecution lifecycle", () => {
|
||||
yield* Deferred.await(draining)
|
||||
expect((yield* claims(database))[sessionID]).toBe(true)
|
||||
|
||||
yield* execution.interrupt(sessionID)
|
||||
expect(yield* execution.interrupt(sessionID)).toBeTrue()
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
expect((yield* claims(database))[sessionID]).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports an idle interrupt as a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = Session.ID.make("ses_idle_cancel")
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, () => Effect.never)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
|
||||
expect(yield* execution.interrupt(sessionID)).toBeFalse()
|
||||
expect(yield* execution.active).not.toContain(sessionID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
|
||||
@@ -48,6 +48,7 @@ const execution = Layer.succeed(
|
||||
Effect.sync(() => {
|
||||
interruptCalls.push(sessionID)
|
||||
interruptContinuations.push(options?.continue)
|
||||
return activeSessions.delete(sessionID)
|
||||
}),
|
||||
wake: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
@@ -193,7 +194,7 @@ describe("Session.prompt", () => {
|
||||
interruptCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.interrupt(sessionID)
|
||||
expect(yield* session.interrupt(sessionID)).toBeFalse()
|
||||
expect(interruptCalls).toEqual([sessionID])
|
||||
expect(wakeCalls).toEqual([])
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("SessionRunCoordinator", () => {
|
||||
drain: () => Effect.void,
|
||||
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
|
||||
})
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
expect(yield* coordinator.interrupt("session", "user")).toBeFalse()
|
||||
yield* coordinator.run("session")
|
||||
expect(reasons).toEqual([undefined])
|
||||
}),
|
||||
@@ -260,7 +260,7 @@ describe("SessionRunCoordinator", () => {
|
||||
|
||||
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(settling)
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
expect(yield* coordinator.interrupt("session", "user")).toBeFalse()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(run)
|
||||
yield* coordinator.run("session")
|
||||
@@ -315,7 +315,7 @@ describe("SessionRunCoordinator", () => {
|
||||
const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* coordinator.wake("session")
|
||||
yield* coordinator.interrupt("session", "user")
|
||||
expect(yield* coordinator.interrupt("session", "user")).toBeTrue()
|
||||
yield* Deferred.await(interrupted)
|
||||
|
||||
const exits = yield* Fiber.awaitAll([first, second, idle])
|
||||
|
||||
@@ -511,7 +511,11 @@ describe("Tool", () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message:
|
||||
'Invalid arguments for tool "transformed":\n- value: Expected boolean\n\nArguments provided:\n{\n "value": "yes"\n}\n\nUpdate the arguments and call the tool again.',
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual(["yes"])
|
||||
|
||||
|
||||
@@ -100,7 +100,11 @@ describe("QuestionTool", () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message:
|
||||
'Invalid arguments for tool "question":\n- questions: Expected a value with a length of at least 1\n\nArguments provided:\n{\n "questions": []\n}\n\nUpdate the arguments and call the tool again.',
|
||||
},
|
||||
})
|
||||
expect(capturedInput()).toBeUndefined()
|
||||
}),
|
||||
|
||||
@@ -144,7 +144,12 @@ test("portable schema failures become tool failures", async () => {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (_value: unknown) => ({ issues: [{ message: "expected a string" }] }),
|
||||
validate: (_value: unknown) => ({
|
||||
issues: [
|
||||
{ path: ["value"], message: "expected a string" },
|
||||
{ path: [{ key: "nested" }, { key: "count" }], message: "expected a positive integer" },
|
||||
],
|
||||
}),
|
||||
jsonSchema: {
|
||||
input: () => ({ type: "string" }),
|
||||
output: () => ({ type: "string" }),
|
||||
@@ -152,19 +157,76 @@ test("portable schema failures become tool failures", async () => {
|
||||
},
|
||||
}
|
||||
|
||||
const error = await Effect.runPromiseExit(
|
||||
execute(
|
||||
{
|
||||
name: "invalid",
|
||||
description: "Invalid",
|
||||
input,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
},
|
||||
1,
|
||||
{} as Tool.Context,
|
||||
const error = await Effect.runPromise(
|
||||
Effect.flip(
|
||||
execute(
|
||||
{
|
||||
name: "invalid",
|
||||
description: "Invalid",
|
||||
input,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
},
|
||||
1,
|
||||
{} as Tool.Context,
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.toString()).toContain("Invalid tool input: expected a string")
|
||||
expect(error).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "invalid":\n- value: expected a string\n- nested.count: expected a positive integer\n\nArguments provided:\n1\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("Effect schema failures use normalized input issues", async () => {
|
||||
const tool: Info = {
|
||||
name: "effect",
|
||||
description: "Effect tool",
|
||||
input: Schema.Struct({
|
||||
value: Schema.String,
|
||||
nested: Schema.Struct({ count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)) }),
|
||||
}),
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("input error prompts limit normalized issues", async () => {
|
||||
const input = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (_value: unknown) => ({
|
||||
issues: Array.from({ length: 6 }, (_, index) => ({ message: `issue ${index + 1}` })),
|
||||
}),
|
||||
jsonSchema: {
|
||||
input: () => ({}),
|
||||
output: () => ({}),
|
||||
},
|
||||
},
|
||||
}
|
||||
const tool: Info = {
|
||||
name: "limited",
|
||||
description: "Limited issues",
|
||||
input,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("canonical results carry metadata with typed output", async () => {
|
||||
@@ -185,8 +247,21 @@ test("canonical results carry metadata with typed output", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("raw JSON schemas are render-only and omitted output means model-only", async () => {
|
||||
const input = { type: "object", properties: { value: { type: "string" } } }
|
||||
test("raw JSON schemas validate and decode tool input", async () => {
|
||||
const input = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
nested: {
|
||||
type: "object",
|
||||
properties: { count: { type: "integer", minimum: 1 } },
|
||||
required: ["count"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ["value"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
const tool: Info = {
|
||||
name: "raw",
|
||||
description: "Raw tool",
|
||||
@@ -197,11 +272,79 @@ test("raw JSON schemas are render-only and omitted output means model-only", asy
|
||||
expect(definition(tool)).toEqual({
|
||||
name: "raw",
|
||||
description: "Raw tool",
|
||||
inputSchema: { type: "object", properties: { value: { type: "string" } } },
|
||||
inputSchema: input,
|
||||
})
|
||||
expect(await Effect.runPromise(execute(tool, { value: 1 }, {} as Tool.Context))).toEqual({
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, {} as Tool.Context))).toEqual({
|
||||
output: undefined,
|
||||
content: [{ type: "text", text: '{"value":1}' }],
|
||||
content: [{ type: "text", text: '{"value":"ok"}' }],
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("raw JSON schemas resolve draft-07 definitions", async () => {
|
||||
const tool: Info = {
|
||||
name: "draft-07",
|
||||
description: "Draft-07 tool",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: { value: { $ref: "#/definitions/value" } },
|
||||
required: ["value"],
|
||||
definitions: { value: { type: "string" } },
|
||||
},
|
||||
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok" }, {} as Tool.Context))).toMatchObject({
|
||||
content: [{ type: "text", text: '{"value":"ok"}' }],
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("raw JSON schemas pass input through when they cannot be imported", async () => {
|
||||
const tool: Info = {
|
||||
name: "invalid-schema",
|
||||
description: "Invalid schema tool",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: { value: { $ref: "#/$defs/missing" } },
|
||||
},
|
||||
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, {} as Tool.Context))).toMatchObject({
|
||||
content: [{ type: "text", text: '{"value":1,"extra":true}' }],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -118,7 +118,8 @@ describe("search tools", () => {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]',
|
||||
message:
|
||||
'Invalid arguments for tool "grep":\n- pattern: Pattern must not be empty\n\nArguments provided:\n{\n "pattern": ""\n}\n\nUpdate the arguments and call the tool again.',
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -115,7 +115,7 @@ const executionNode = makeGlobalNode({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -88,7 +88,7 @@ const executionNode = makeGlobalNode({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -41,7 +41,7 @@ const driver = WorkspaceDriver.make({
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Workspace.configured({ idleThreshold: "5 minutes", pollInterval: "1 minute" })]),
|
||||
[[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver })]],
|
||||
[[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver, other: driver })]],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ it.effect("rejects unregistered workspace providers", () =>
|
||||
it.effect("creates and persists an ID without provisioning", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
|
||||
expect(workspaceID.startsWith("wrk_")).toBe(true)
|
||||
expect(calls).toEqual([])
|
||||
@@ -89,10 +89,74 @@ it.effect("creates and persists an ID without provisioning", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates a workspace with a caller-supplied ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const id = Workspace.ID.create()
|
||||
|
||||
expect(yield* workspace.create({ id, provider: "fake" })).toBe(id)
|
||||
expect(
|
||||
yield* Database.Service.use(({ db }) =>
|
||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get(),
|
||||
).pipe(Effect.orDie),
|
||||
).toMatchObject({ id, provider: "fake", binding: null })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reuses a caller-supplied ID with the same provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const id = Workspace.ID.create()
|
||||
|
||||
expect(yield* workspace.create({ id, provider: "fake" })).toBe(id)
|
||||
expect(yield* workspace.create({ id, provider: "fake" })).toBe(id)
|
||||
expect(
|
||||
yield* Database.Service.use(({ db }) =>
|
||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).all(),
|
||||
).pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
expect(calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a caller-supplied ID already assigned to another provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const id = Workspace.ID.create()
|
||||
yield* workspace.create({ id, provider: "fake" })
|
||||
|
||||
expect(yield* workspace.create({ id, provider: "other" }).pipe(Effect.flip)).toEqual(
|
||||
new Workspace.CreateConflict({ workspaceID: id, provider: "other", existingProvider: "fake" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves an existing caller-supplied ID before provider lookup", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const id = Workspace.ID.create()
|
||||
yield* Database.Service.use(({ db }) =>
|
||||
db
|
||||
.insert(WorkspaceTable)
|
||||
.values({ id, provider: "missing", binding: null, created_at: 0, last_used_at: 0 })
|
||||
.run(),
|
||||
).pipe(Effect.orDie)
|
||||
|
||||
expect(yield* workspace.create({ id, provider: "missing" })).toBe(id)
|
||||
expect(yield* workspace.create({ id, provider: "another-missing" }).pipe(Effect.flip)).toEqual(
|
||||
new Workspace.CreateConflict({
|
||||
workspaceID: id,
|
||||
provider: "another-missing",
|
||||
existingProvider: "missing",
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("destroys an unprovisioned workspace through the driver with a null binding", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
|
||||
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
|
||||
expect(calls).toEqual([{ operation: "destroy", binding: null }])
|
||||
@@ -117,7 +181,7 @@ it.effect("succeeds without calling the driver when the workspace does not exist
|
||||
it.effect("reports whether destroy removed an existing workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
|
||||
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
|
||||
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: false })
|
||||
@@ -128,7 +192,7 @@ it.effect("reports whether destroy removed an existing workspace", () =>
|
||||
it.effect("starts eager provisioning in the background and lets callers join it", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
const eager = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
@@ -147,7 +211,7 @@ it.effect("starts eager provisioning in the background and lets callers join it"
|
||||
it.effect("starts lazy provisioning on the first spawn", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
const environment = yield* workspace.connect(workspaceID)
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
@@ -166,7 +230,7 @@ it.effect("starts lazy provisioning on the first spawn", () =>
|
||||
it.effect("shares provisioning between concurrent first spawns", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
const environment = yield* workspace.connect(workspaceID)
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
@@ -190,7 +254,7 @@ it.effect("shares provisioning between concurrent first spawns", () =>
|
||||
it.effect("keeps shared provisioning alive when a waiter is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
const owner = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
@@ -208,7 +272,7 @@ it.effect("keeps shared provisioning alive when a waiter is interrupted", () =>
|
||||
it.effect("interrupts in-flight provisioning on destroy and fails waiters with NotFound", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
@@ -229,7 +293,7 @@ it.effect("interrupts in-flight provisioning on destroy and fails waiters with N
|
||||
it.effect("shares a failed attempt and retries the same workspace ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
let fail = true
|
||||
@@ -263,7 +327,7 @@ it.effect("shares a failed attempt and retries the same workspace ID", () =>
|
||||
it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create({ provider: "fake" })
|
||||
const created = yield* workspace.provision(workspaceID)
|
||||
|
||||
expect(created.id).toBe(workspaceID)
|
||||
@@ -298,7 +362,7 @@ it.effect("persists the workspace lifecycle and reconnects after idle suspension
|
||||
it.effect("surfaces wake failures through the spawn error channel", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const created = yield* workspace.provision(yield* workspace.create("fake"))
|
||||
const created = yield* workspace.provision(yield* workspace.create({ provider: "fake" }))
|
||||
const environment = yield* workspace.connect(created.id)
|
||||
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("connect"))).pipe(Effect.exit)
|
||||
|
||||
|
||||
+199
-46
@@ -3753,8 +3753,15 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
"200": {
|
||||
"description": "SessionInterruptResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionInterruptResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
@@ -3794,7 +3801,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Returns interrupted=true when an active execution was interrupted and false for the idle no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
|
||||
"summary": "Interrupt session execution"
|
||||
}
|
||||
},
|
||||
@@ -4441,48 +4448,7 @@
|
||||
"post": {
|
||||
"tags": ["generate"],
|
||||
"operationId": "v2.generate.text",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -4533,7 +4499,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Run one stateless model generation at the requested location and return the assistant text. Uses the location's default model when none is specified.",
|
||||
"description": "Run one stateless model generation using the server's base configuration and return the assistant text. Uses the base configuration's default model when none is specified.",
|
||||
"summary": "Generate text",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -10648,6 +10614,166 @@
|
||||
"summary": "Refresh worktrees"
|
||||
}
|
||||
},
|
||||
"/api/workspace": {
|
||||
"post": {
|
||||
"tags": ["workspace"],
|
||||
"operationId": "v2.workspace.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "ProviderNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a logical workspace. A caller-supplied ID is idempotent when retried with the same provider; reusing it with another provider returns a conflict.",
|
||||
"summary": "Create workspace",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/workspace/{workspaceID}": {
|
||||
"delete": {
|
||||
"tags": ["workspace"],
|
||||
"operationId": "v2.workspace.destroy",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "workspaceID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reports whether this request destroyed an existing workspace.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkspaceDestroyResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Make a workspace not exist. This operation is idempotent: an already-missing workspace succeeds with `destroyed: false`, while a workspace removed by this request returns `destroyed: true`.",
|
||||
"summary": "Destroy workspace"
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": ["vcs"],
|
||||
@@ -16411,6 +16537,17 @@
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionInterruptResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interrupted": {
|
||||
"type": "boolean",
|
||||
"description": "Whether an active execution owned by this OpenCode process was interrupted."
|
||||
}
|
||||
},
|
||||
"required": ["interrupted"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionLogItemEncoded": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json"
|
||||
@@ -17075,6 +17212,18 @@
|
||||
"required": ["url", "time"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WorkspaceDestroyResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"destroyed": {
|
||||
"type": "boolean",
|
||||
"description": "True when this request transitioned the workspace from existing to destroyed."
|
||||
}
|
||||
},
|
||||
"required": ["destroyed"],
|
||||
"additionalProperties": false,
|
||||
"description": "Reports whether this request destroyed an existing workspace."
|
||||
},
|
||||
"Worktree.Directory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17231,6 +17380,10 @@
|
||||
"name": "worktree",
|
||||
"description": "Project worktree management routes."
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"description": "Workspace lifecycle routes."
|
||||
},
|
||||
{
|
||||
"name": "vcs",
|
||||
"description": "Location-scoped version control routes."
|
||||
|
||||
@@ -39,7 +39,6 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof AgentGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof PluginGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ModelGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof GenerateGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProviderGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof IntegrationGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof WebSearchGroup, LocationId>
|
||||
@@ -88,6 +87,7 @@ type ApiGroups<
|
||||
| typeof MigrationGroup
|
||||
| typeof WorktreeGroup
|
||||
| typeof WorkspaceGroup
|
||||
| typeof GenerateGroup
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService>
|
||||
@@ -155,7 +155,7 @@ const makeApiFromGroup = <
|
||||
.add(makeSessionGroup(sessionLocationMiddleware))
|
||||
.add(MessageGroup)
|
||||
.add(ModelGroup.middleware(locationMiddleware))
|
||||
.add(GenerateGroup.middleware(locationMiddleware))
|
||||
.add(GenerateGroup)
|
||||
.add(ProviderGroup.middleware(locationMiddleware))
|
||||
.add(IntegrationGroup.middleware(locationMiddleware))
|
||||
.add(McpGroup.middleware(locationMiddleware))
|
||||
|
||||
@@ -2,12 +2,10 @@ import { Model } from "@opencode-ai/schema/model"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const GenerateGroup = HttpApiGroup.make("server.generate")
|
||||
.add(
|
||||
HttpApiEndpoint.post("generate.text", "/api/generate", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({
|
||||
prompt: Schema.String,
|
||||
model: Model.Ref.pipe(Schema.optional),
|
||||
@@ -16,16 +14,14 @@ export const GenerateGroup = HttpApiGroup.make("server.generate")
|
||||
data: Schema.Struct({ text: Schema.String }),
|
||||
}).annotate({ identifier: "GenerateTextResponse" }),
|
||||
error: [InvalidRequestError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.generate.text",
|
||||
summary: "Generate text",
|
||||
description:
|
||||
"Run one stateless model generation at the requested location and return the assistant text. Uses the location's default model when none is specified.",
|
||||
}),
|
||||
),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.generate.text",
|
||||
summary: "Generate text",
|
||||
description:
|
||||
"Run one stateless model generation using the server's base configuration and return the assistant text. Uses the base configuration's default model when none is specified.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -664,7 +664,11 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: { continue: BooleanFromString.pipe(Schema.optional) },
|
||||
success: HttpApiSchema.NoContent,
|
||||
success: Schema.Struct({
|
||||
interrupted: Schema.Boolean.annotate({
|
||||
description: "Whether an active execution owned by this OpenCode process was interrupted.",
|
||||
}),
|
||||
}).annotate({ identifier: "SessionInterruptResponse" }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
@@ -673,7 +677,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
identifier: "v2.session.interrupt",
|
||||
summary: "Interrupt session execution",
|
||||
description:
|
||||
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
|
||||
"Interrupt active execution owned by this OpenCode process. Returns interrupted=true when an active execution was interrupted and false for the idle no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { UnknownError } from "../errors.js"
|
||||
import { ConflictError, ProviderNotFoundError, UnknownError } from "../errors.js"
|
||||
|
||||
export const WorkspaceGroup = HttpApiGroup.make("server.workspace")
|
||||
.add(
|
||||
HttpApiEndpoint.post("workspace.create", "/api/workspace", {
|
||||
payload: Schema.Struct({
|
||||
id: Workspace.ID.pipe(Schema.optional),
|
||||
provider: Schema.String,
|
||||
}),
|
||||
success: Schema.Struct({ data: Workspace.ID }),
|
||||
error: [ConflictError, ProviderNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.workspace.create",
|
||||
summary: "Create workspace",
|
||||
description:
|
||||
"Create a logical workspace. A caller-supplied ID is idempotent when retried with the same provider; reusing it with another provider returns a conflict.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("workspace.destroy", "/api/workspace/:workspaceID", {
|
||||
params: { workspaceID: Workspace.ID },
|
||||
|
||||
@@ -16,7 +16,7 @@ export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
|
||||
readonly sessions: OpenCodeClient["session"]
|
||||
readonly events: OpenCodeClient["event"]
|
||||
readonly workspace: {
|
||||
readonly create: (options: { readonly provider: string }) => ReturnType<Workspace.Interface["create"]>
|
||||
readonly create: Workspace.Interface["create"]
|
||||
readonly provision: (options: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
}) => ReturnType<Workspace.Interface["provision"]>
|
||||
@@ -44,7 +44,7 @@ export const create: (
|
||||
sessions: client.session,
|
||||
events: client.event,
|
||||
workspace: {
|
||||
create: ({ provider }: { readonly provider: string }) => host.workspace.create(provider),
|
||||
create: host.workspace.create,
|
||||
provision: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.provision(workspaceID),
|
||||
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.destroy(workspaceID),
|
||||
},
|
||||
|
||||
@@ -462,8 +462,11 @@ it.live("configures workspace providers through the SDK facade", () =>
|
||||
},
|
||||
})
|
||||
const opencode = yield* fixture.sdk.OpenCode.create({ workspaceProviders: { fake: driver } })
|
||||
const workspaceID = yield* opencode.workspace.create({ provider: "fake" })
|
||||
const requestedID = fixture.sdk.Workspace.ID.create()
|
||||
const workspaceID = yield* opencode.workspace.create({ id: requestedID, provider: "fake" })
|
||||
|
||||
expect(workspaceID).toBe(requestedID)
|
||||
expect(yield* opencode.workspace.create({ id: requestedID, provider: "fake" })).toBe(requestedID)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
const workspace = yield* opencode.workspace.provision({ workspaceID })
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Generate } from "@opencode-ai/core/generate"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
|
||||
export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const services = locations.get(Location.Ref.make({ directory: AbsolutePath.make(global.config) }))
|
||||
return handlers.handle(
|
||||
"generate.text",
|
||||
Effect.fn("server.generate.text")(function* (request) {
|
||||
const generate = yield* Generate.Service
|
||||
const generate = yield* Generate.Service.pipe(Effect.provide(services))
|
||||
const text = yield* generate
|
||||
.text(request.payload)
|
||||
.pipe(
|
||||
|
||||
@@ -9,9 +9,10 @@ import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
const authorize = <A, R>(effect: Effect.Effect<A, Integration.AuthorizationError, R>) =>
|
||||
effect.pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
(error) =>
|
||||
new InvalidRequestError({
|
||||
message: "Authentication failed",
|
||||
message:
|
||||
error.cause instanceof Error && error.cause.message.trim() ? error.cause.message : "Authentication failed",
|
||||
kind: "integration_authorization",
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -606,8 +606,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.interrupt",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.interrupt(ctx.params.sessionID, { continue: ctx.query.continue })
|
||||
return HttpApiSchema.NoContent.make()
|
||||
return { interrupted: yield* session.interrupt(ctx.params.sessionID, { continue: ctx.query.continue }) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { UnknownError } from "@opencode-ai/protocol/errors"
|
||||
import { ConflictError, ProviderNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
@@ -8,18 +8,36 @@ export const WorkspaceHandler = HttpApiBuilder.group(Api, "server.workspace", (h
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
|
||||
return handlers.handle("workspace.destroy", (ctx) =>
|
||||
workspace.destroy(ctx.params.workspaceID).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new UnknownError({
|
||||
message:
|
||||
error._tag === "WorkspaceDriver.ProviderNotFound"
|
||||
? `Workspace provider not found: ${error.provider}`
|
||||
: (error.message ?? "Workspace provider failed to destroy the workspace"),
|
||||
}),
|
||||
return handlers
|
||||
.handle("workspace.create", (ctx) =>
|
||||
workspace.create(ctx.payload).pipe(
|
||||
Effect.map((workspaceID) => ({ data: workspaceID })),
|
||||
Effect.catchTags({
|
||||
"Workspace.CreateConflict": (error) =>
|
||||
new ConflictError({
|
||||
resource: error.workspaceID,
|
||||
message: `Workspace ${error.workspaceID} already uses provider ${error.existingProvider}, not ${error.provider}`,
|
||||
}),
|
||||
"WorkspaceDriver.ProviderNotFound": (error) =>
|
||||
new ProviderNotFoundError({
|
||||
providerID: error.provider,
|
||||
message: `Workspace provider not found: ${error.provider}`,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
.handle("workspace.destroy", (ctx) =>
|
||||
workspace.destroy(ctx.params.workspaceID).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new UnknownError({
|
||||
message:
|
||||
error._tag === "WorkspaceDriver.ProviderNotFound"
|
||||
? `Workspace provider not found: ${error.provider}`
|
||||
: (error.message ?? "Workspace provider failed to destroy the workspace"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { expect } from "bun:test"
|
||||
import { createServer, type Server } from "node:http"
|
||||
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
@@ -10,6 +13,36 @@ const options = {
|
||||
fs: { filewatcher: false },
|
||||
} as const
|
||||
|
||||
type Handler = (request: Request) => Promise<Response>
|
||||
|
||||
function occupy(server: Server, port: number) {
|
||||
return Effect.callback<void, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(port, "localhost", () => resume(Effect.void))
|
||||
})
|
||||
}
|
||||
|
||||
const ready = (handler: Handler) =>
|
||||
Effect.promise(() => handler(new Request("http://opencode.local/api/model/default")))
|
||||
|
||||
const connectOpenAI = (handler: Handler) =>
|
||||
Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/integration/openai/connect/oauth", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ methodID: "chatgpt-browser" }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const workspaceDriver = WorkspaceDriver.make({
|
||||
create: ({ workspaceID }) => Effect.succeed({ binding: { workspaceID } }),
|
||||
connect: () => Effect.succeed(makeMemoryDriver()),
|
||||
suspendForIdle: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
})
|
||||
|
||||
it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make({ ...options, password: "secret" })
|
||||
@@ -53,6 +86,68 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: string[] = []
|
||||
const blocker = createServer((request, response) => {
|
||||
requests.push(request.url ?? "")
|
||||
response.end("cancelled", () => blocker.close())
|
||||
})
|
||||
yield* occupy(blocker, 1455)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close()))
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
yield* ready(handler)
|
||||
const response = yield* connectOpenAI(handler)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(requests).toContain("/cancel")
|
||||
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
|
||||
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1455/auth/callback")
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: string[] = []
|
||||
const blocker = createServer((request, response) => {
|
||||
requests.push(request.url ?? "")
|
||||
response.end("still running")
|
||||
})
|
||||
yield* occupy(blocker, 1455)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close()))
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
yield* ready(handler)
|
||||
const response = yield* connectOpenAI(handler)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(requests).toContain("/cancel")
|
||||
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
|
||||
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1457/auth/callback")
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("explains how to recover when both OpenAI OAuth callback ports are busy", () =>
|
||||
Effect.gen(function* () {
|
||||
const preferred = createServer((_request, response) => response.end("still running"))
|
||||
const fallback = createServer()
|
||||
yield* occupy(preferred, 1455)
|
||||
yield* occupy(fallback, 1457)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => preferred.close()))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => fallback.close()))
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
yield* ready(handler)
|
||||
const response = yield* connectOpenAI(handler)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
_tag: "InvalidRequestError",
|
||||
message:
|
||||
"OpenAI browser login needs local port 1455 or 1457, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.",
|
||||
kind: "integration_authorization",
|
||||
})
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("treats destroying a missing workspace as success", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
@@ -69,6 +164,48 @@ it.live("treats destroying a missing workspace as success", () =>
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("creates idempotent caller-identified workspaces through the HttpApi", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options, {
|
||||
overrides: [
|
||||
[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: workspaceDriver, other: workspaceDriver })],
|
||||
],
|
||||
})
|
||||
const id = Workspace.ID.create()
|
||||
const create = (body: unknown) =>
|
||||
Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/workspace", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const supplied = yield* create({ id, provider: "fake" })
|
||||
expect(supplied.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => supplied.json())).toEqual({ data: id })
|
||||
|
||||
const repeated = yield* create({ id, provider: "fake" })
|
||||
expect(repeated.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => repeated.json())).toEqual({ data: id })
|
||||
|
||||
const conflict = yield* create({ id, provider: "other" })
|
||||
expect(conflict.status).toBe(409)
|
||||
expect(yield* Effect.promise(() => conflict.json())).toMatchObject({
|
||||
_tag: "ConflictError",
|
||||
resource: id,
|
||||
})
|
||||
|
||||
expect((yield* create({ id: "invalid", provider: "fake" })).status).toBe(400)
|
||||
|
||||
const minted = yield* create({ provider: "fake" })
|
||||
expect(minted.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => minted.json())).toMatchObject({ data: expect.stringMatching(/^wrk_/) })
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("serves the session view operation and missing-session error", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Generate } from "@opencode-ai/core/generate"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Effect, Layer, Predicate } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const generate = makeLocationNode({
|
||||
service: Generate.Service,
|
||||
layer: Layer.effect(
|
||||
Generate.Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
return Generate.Service.of({
|
||||
text: () =>
|
||||
config.entries().pipe(
|
||||
Effect.map((entries) =>
|
||||
JSON.stringify({
|
||||
model: Config.latest(entries, "model"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
}),
|
||||
),
|
||||
deps: [Config.node],
|
||||
})
|
||||
|
||||
it.live("uses base configuration without depending on process.cwd()", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-generate-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ model: "base/default" })),
|
||||
fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ model: "project/default" })),
|
||||
]),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: global },
|
||||
fs: { filewatcher: false },
|
||||
},
|
||||
{ overrides: [[Generate.node, generate]] },
|
||||
)
|
||||
|
||||
expect(global).not.toBe(process.cwd())
|
||||
expect(yield* request(handler, new URL("http://opencode.local/api/generate"))).toEqual({
|
||||
model: { providerID: "base", model: "default" },
|
||||
})
|
||||
|
||||
const legacy = new URL("http://opencode.local/api/generate")
|
||||
legacy.searchParams.set("location[directory]", project)
|
||||
expect(yield* request(handler, legacy)).toEqual({
|
||||
model: { providerID: "base", model: "default" },
|
||||
})
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
function request(handler: (request: Request) => Promise<Response>, url: URL) {
|
||||
return Effect.promise(() =>
|
||||
handler(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ prompt: "hello" }),
|
||||
}),
|
||||
).then(async (response) => {
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = await response.json()
|
||||
if (!Predicate.isObject(body) || !Predicate.isObject(body.data) || typeof body.data.text !== "string")
|
||||
throw new Error("Expected a generate response")
|
||||
const result: unknown = JSON.parse(body.data.text)
|
||||
return result
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ test("reflows held tabs when the pointer leaves the strip", async () => {
|
||||
|
||||
await app.mockMouse.moveTo(0, 1)
|
||||
await app.mockMouse.moveTo(20, 0)
|
||||
await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[20] === "✕")
|
||||
await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[20] === "✕", { maxPasses: 100 })
|
||||
expect(Array.from(app.captureCharFrame().split("\n")[0] ?? "")[22]).not.toBe("✕")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
||||
@@ -263,7 +263,11 @@ test("stores session tabs for the current working directory by default", async (
|
||||
|
||||
try {
|
||||
const file = path.join(setup.state, "test", "tui", "tabs.json")
|
||||
await wait(() => Bun.file(file).size > 0)
|
||||
await wait(async () => {
|
||||
if (!(await Bun.file(file).exists())) return false
|
||||
const stored = await Bun.file(file).json()
|
||||
return stored.cwd[directory]?.tabs.some((tab: { sessionID: string }) => tab.sessionID === "first")
|
||||
})
|
||||
const stored = await Bun.file(file).json()
|
||||
expect(stored.global).toEqual({ tabs: [], unread: {} })
|
||||
expect(Object.keys(stored.cwd)).toEqual([directory])
|
||||
|
||||
@@ -1493,7 +1493,7 @@ describe("V2 mini transport", () => {
|
||||
files: [],
|
||||
includeFiles: true,
|
||||
})
|
||||
const interrupt = spyOn(second.session, "interrupt").mockImplementation(() => ok(undefined))
|
||||
const interrupt = spyOn(second.session, "interrupt").mockImplementation(() => ok({ interrupted: true }))
|
||||
await transport.interruptActiveTurn()
|
||||
|
||||
expect(prompt).toHaveBeenCalled()
|
||||
@@ -2363,7 +2363,7 @@ describe("V2 mini transport", () => {
|
||||
admitted = true
|
||||
return ok({ data: promptAdmission(request) })
|
||||
})
|
||||
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
|
||||
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok({ interrupted: true }))
|
||||
const controller = new AbortController()
|
||||
const turn = transport.runPromptTurn({
|
||||
agent: undefined,
|
||||
@@ -2500,7 +2500,7 @@ describe("V2 mini transport", () => {
|
||||
})
|
||||
}) as never,
|
||||
)
|
||||
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
|
||||
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok({ interrupted: true }))
|
||||
|
||||
const turn = transport.runPromptTurn({
|
||||
agent: undefined,
|
||||
|
||||
@@ -57,20 +57,19 @@ Or you can set it up manually.
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run OpenCode
|
||||
- name: Run OpenCode
|
||||
uses: anomalyco/opencode/github@latest
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
with:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
# share: true
|
||||
# github_token: xxxx
|
||||
```
|
||||
|
||||
3. **Store the API keys in secrets**
|
||||
@@ -85,19 +84,30 @@ Or you can set it up manually.
|
||||
- `agent`: The agent to use. Must be a primary agent. Falls back to `default_agent` from config or `"build"` if not found.
|
||||
- `share`: Whether to share the OpenCode session. Defaults to **true** for public repositories.
|
||||
- `prompt`: Optional custom prompt to override the default behavior. Use this to customize how OpenCode processes requests.
|
||||
- `token`: Optional GitHub access token for performing operations such as creating comments, committing changes, and opening pull requests. By default, OpenCode uses the installation access token from the OpenCode GitHub App, so commits, comments, and pull requests appear as coming from the app.
|
||||
- `mentions`: Comma-separated list of trigger phrases, case-insensitive. Defaults to `/opencode,/oc`.
|
||||
- `variant`: Model variant for provider-specific reasoning effort, for example `high`, `max`, or `minimal`.
|
||||
- `oidc_base_url`: Base URL for the OIDC token exchange API. Only needed when running a custom GitHub App install. Defaults to `https://api.opencode.ai`.
|
||||
- `use_github_token`: Set to `true` to use a caller-provided `GITHUB_TOKEN` instead of exchanging an OIDC token for an OpenCode App installation token. Defaults to `false`.
|
||||
|
||||
Alternatively, you can use the GitHub Action runner's [built-in `GITHUB_TOKEN`](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token) without installing the OpenCode GitHub App. Just make sure to grant the required permissions in your workflow:
|
||||
Use this mode to run without installing the OpenCode GitHub App. Pass the token through `env` and grant the permissions required by your workflow:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: anomalyco/opencode/github@latest
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
use_github_token: true
|
||||
```
|
||||
|
||||
You can also use a [personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)(PAT) if preferred.
|
||||
`id-token: write` is not required in this mode because OIDC exchange is skipped. To use a [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) or another GitHub App token, store it as a secret and pass that secret as `GITHUB_TOKEN` instead.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
## Local development
|
||||
|
||||
- Run `bun dev` from this package and use the local URL printed by Astro.
|
||||
- Do not run `bun typecheck`, `bun run build`, or another Astro process while the dev server is running. They share the Vite dependency cache and can break the active dev server. Leave validation to the user when the dev server is active.
|
||||
|
||||
## Validation
|
||||
|
||||
|
||||
+211
-149
@@ -1915,36 +1915,15 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Session.Inbox.User"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1979,28 +1958,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "CommandEvaluationError",
|
||||
"description": "CommandExecutionError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandEvaluationErrorEncoded"
|
||||
"$ref": "#/components/schemas/CommandExecutionErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.",
|
||||
"description": "Execute a slash command callback immediately.",
|
||||
"summary": "Run command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -2008,49 +1977,11 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"arguments": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
@@ -2079,19 +2010,9 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
"required": ["command", "text"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -3832,8 +3753,15 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
"200": {
|
||||
"description": "SessionInterruptResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionInterruptResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
@@ -3873,7 +3801,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Returns interrupted=true when an active execution was interrupted and false for the idle no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
|
||||
"summary": "Interrupt session execution"
|
||||
}
|
||||
},
|
||||
@@ -4520,48 +4448,7 @@
|
||||
"post": {
|
||||
"tags": ["generate"],
|
||||
"operationId": "v2.generate.text",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -4612,7 +4499,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Run one stateless model generation at the requested location and return the assistant text. Uses the location's default model when none is specified.",
|
||||
"description": "Run one stateless model generation using the server's base configuration and return the assistant text. Uses the base configuration's default model when none is specified.",
|
||||
"summary": "Generate text",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -10727,6 +10614,166 @@
|
||||
"summary": "Refresh worktrees"
|
||||
}
|
||||
},
|
||||
"/api/workspace": {
|
||||
"post": {
|
||||
"tags": ["workspace"],
|
||||
"operationId": "v2.workspace.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "ProviderNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a logical workspace. A caller-supplied ID is idempotent when retried with the same provider; reusing it with another provider returns a conflict.",
|
||||
"summary": "Create workspace",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/workspace/{workspaceID}": {
|
||||
"delete": {
|
||||
"tags": ["workspace"],
|
||||
"operationId": "v2.workspace.destroy",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "workspaceID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reports whether this request destroyed an existing workspace.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkspaceDestroyResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Make a workspace not exist. This operation is idempotent: an already-missing workspace succeeds with `destroyed: false`, while a workspace removed by this request returns `destroyed: true`.",
|
||||
"summary": "Destroy workspace"
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": ["vcs"],
|
||||
@@ -11658,31 +11705,19 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"template": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["name", "template"],
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CommandEvaluationErrorEncoded": {
|
||||
"CommandExecutionErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["CommandEvaluationError"]
|
||||
"enum": ["CommandExecutionError"]
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
@@ -16502,6 +16537,17 @@
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionInterruptResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interrupted": {
|
||||
"type": "boolean",
|
||||
"description": "Whether an active execution owned by this OpenCode process was interrupted."
|
||||
}
|
||||
},
|
||||
"required": ["interrupted"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionLogItemEncoded": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json"
|
||||
@@ -17166,6 +17212,18 @@
|
||||
"required": ["url", "time"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WorkspaceDestroyResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"destroyed": {
|
||||
"type": "boolean",
|
||||
"description": "True when this request transitioned the workspace from existing to destroyed."
|
||||
}
|
||||
},
|
||||
"required": ["destroyed"],
|
||||
"additionalProperties": false,
|
||||
"description": "Reports whether this request destroyed an existing workspace."
|
||||
},
|
||||
"Worktree.Directory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17322,6 +17380,10 @@
|
||||
"name": "worktree",
|
||||
"description": "Project worktree management routes."
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"description": "Workspace lifecycle routes."
|
||||
},
|
||||
{
|
||||
"name": "vcs",
|
||||
"description": "Location-scoped version control routes."
|
||||
|
||||
+211
-149
@@ -1915,36 +1915,15 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Session.Inbox.User"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1979,28 +1958,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "CommandEvaluationError",
|
||||
"description": "CommandExecutionError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandEvaluationErrorEncoded"
|
||||
"$ref": "#/components/schemas/CommandExecutionErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.",
|
||||
"description": "Execute a slash command callback immediately.",
|
||||
"summary": "Run command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -2008,49 +1977,11 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"arguments": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
@@ -2079,19 +2010,9 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
"required": ["command", "text"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -3832,8 +3753,15 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
"200": {
|
||||
"description": "SessionInterruptResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionInterruptResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
@@ -3873,7 +3801,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Returns interrupted=true when an active execution was interrupted and false for the idle no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
|
||||
"summary": "Interrupt session execution"
|
||||
}
|
||||
},
|
||||
@@ -4520,48 +4448,7 @@
|
||||
"post": {
|
||||
"tags": ["generate"],
|
||||
"operationId": "v2.generate.text",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -4612,7 +4499,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Run one stateless model generation at the requested location and return the assistant text. Uses the location's default model when none is specified.",
|
||||
"description": "Run one stateless model generation using the server's base configuration and return the assistant text. Uses the base configuration's default model when none is specified.",
|
||||
"summary": "Generate text",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -10727,6 +10614,166 @@
|
||||
"summary": "Refresh worktrees"
|
||||
}
|
||||
},
|
||||
"/api/workspace": {
|
||||
"post": {
|
||||
"tags": ["workspace"],
|
||||
"operationId": "v2.workspace.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "ProviderNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a logical workspace. A caller-supplied ID is idempotent when retried with the same provider; reusing it with another provider returns a conflict.",
|
||||
"summary": "Create workspace",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/workspace/{workspaceID}": {
|
||||
"delete": {
|
||||
"tags": ["workspace"],
|
||||
"operationId": "v2.workspace.destroy",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "workspaceID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reports whether this request destroyed an existing workspace.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkspaceDestroyResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Make a workspace not exist. This operation is idempotent: an already-missing workspace succeeds with `destroyed: false`, while a workspace removed by this request returns `destroyed: true`.",
|
||||
"summary": "Destroy workspace"
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": ["vcs"],
|
||||
@@ -11658,31 +11705,19 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"template": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["name", "template"],
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CommandEvaluationErrorEncoded": {
|
||||
"CommandExecutionErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["CommandEvaluationError"]
|
||||
"enum": ["CommandExecutionError"]
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
@@ -16502,6 +16537,17 @@
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionInterruptResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interrupted": {
|
||||
"type": "boolean",
|
||||
"description": "Whether an active execution owned by this OpenCode process was interrupted."
|
||||
}
|
||||
},
|
||||
"required": ["interrupted"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionLogItemEncoded": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json"
|
||||
@@ -17166,6 +17212,18 @@
|
||||
"required": ["url", "time"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WorkspaceDestroyResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"destroyed": {
|
||||
"type": "boolean",
|
||||
"description": "True when this request transitioned the workspace from existing to destroyed."
|
||||
}
|
||||
},
|
||||
"required": ["destroyed"],
|
||||
"additionalProperties": false,
|
||||
"description": "Reports whether this request destroyed an existing workspace."
|
||||
},
|
||||
"Worktree.Directory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17322,6 +17380,10 @@
|
||||
"name": "worktree",
|
||||
"description": "Project worktree management routes."
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"description": "Workspace lifecycle routes."
|
||||
},
|
||||
{
|
||||
"name": "vcs",
|
||||
"description": "Location-scoped version control routes."
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "Effect"
|
||||
---
|
||||
|
||||
`@opencode-ai/client/effect` is the Effect-native client for the OpenCode HTTP API. It returns typed Effects and Streams
|
||||
and decodes responses into OpenCode schema values.
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/client@beta effect
|
||||
```
|
||||
|
||||
## Create a client
|
||||
|
||||
Create a client with the server URL, then call methods grouped by API resource.
|
||||
|
||||
```ts
|
||||
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
|
||||
const session = yield* client.session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
|
||||
})
|
||||
yield* client.session.prompt({
|
||||
sessionID: session.id,
|
||||
text: "Review the current changes",
|
||||
})
|
||||
return session
|
||||
})
|
||||
|
||||
const session = await Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)))
|
||||
```
|
||||
|
||||
## Headers and requests
|
||||
|
||||
Pass default headers to `OpenCode.make`. Each operation also accepts request options for cancellation or per-request
|
||||
headers.
|
||||
|
||||
```ts
|
||||
const client = yield* OpenCode.make({
|
||||
baseUrl: "https://opencode.example.com",
|
||||
headers: { authorization: `Bearer ${process.env.OPENCODE_TOKEN}` },
|
||||
})
|
||||
|
||||
const sessions = yield* client.session.list(undefined, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
```
|
||||
|
||||
## Stream events
|
||||
|
||||
Streaming operations such as `event.subscribe()` and `session.log()` return Effect Streams.
|
||||
|
||||
```ts
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
yield* client.event.subscribe().pipe(
|
||||
Stream.runForEach((event) => Effect.logInfo("OpenCode event", { type: event.type })),
|
||||
)
|
||||
```
|
||||
|
||||
## Local background service
|
||||
|
||||
The Node-only `@opencode-ai/client/effect/service` entrypoint discovers, starts, authenticates, and stops the local
|
||||
background service as Effects.
|
||||
|
||||
```sh
|
||||
bun add @effect/platform-node
|
||||
```
|
||||
|
||||
Create an authenticated client for the ensured service.
|
||||
|
||||
```ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const endpoint = yield* Service.ensure()
|
||||
const client = yield* OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
return yield* client.health.get()
|
||||
})
|
||||
|
||||
const health = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
```
|
||||
|
||||
Discover without starting, or stop the exact registered service.
|
||||
|
||||
```ts
|
||||
const endpoint = yield* Service.discover()
|
||||
yield* Service.stop()
|
||||
```
|
||||
+1
-66
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Client"
|
||||
title: "JavaScript"
|
||||
---
|
||||
|
||||
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
|
||||
@@ -110,68 +110,3 @@ const endpoint = await Service.ensure({
|
||||
|
||||
Omit these options to use the standard registration path and
|
||||
`opencode serve --service` command.
|
||||
|
||||
## Effect
|
||||
|
||||
OpenCode provides a first-class Effect client through the
|
||||
`@opencode-ai/client/effect` entrypoint. It returns typed Effects and Streams
|
||||
and decodes responses into OpenCode schema values.
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/client@beta effect
|
||||
```
|
||||
|
||||
### Create a client
|
||||
|
||||
```ts
|
||||
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
|
||||
const session = yield* client.session.create({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
}),
|
||||
})
|
||||
|
||||
return yield* client.session.get({ sessionID: session.id })
|
||||
})
|
||||
|
||||
const session = await Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)))
|
||||
```
|
||||
|
||||
Streaming operations, including `client.event.subscribe()` and
|
||||
`client.session.log(...)`, return Effect `Stream` values.
|
||||
|
||||
### Local background service
|
||||
|
||||
The Node-only `@opencode-ai/client/effect/service` entrypoint exposes the same
|
||||
operations as Effect values. Add `@effect/platform-node` and provide its
|
||||
filesystem layer when running them.
|
||||
|
||||
```sh
|
||||
bun add @effect/platform-node
|
||||
```
|
||||
|
||||
```ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const endpoint = yield* Service.ensure()
|
||||
const client = yield* OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
return yield* client.health.get()
|
||||
})
|
||||
|
||||
const health = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Build"
|
||||
title: "Intro"
|
||||
---
|
||||
|
||||
<CardGroup cols={1}>
|
||||
@@ -16,7 +16,3 @@ title: "Build"
|
||||
around it.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Callout type="warning">
|
||||
The plugin API, client, and SDK are still being finalized during beta and may change before OpenCode 2.0 is stable.
|
||||
</Callout>
|
||||
|
||||
@@ -1,494 +0,0 @@
|
||||
---
|
||||
title: "Plugins"
|
||||
---
|
||||
|
||||
Plugins extend OpenCode in-process. They can transform agents, models, commands,
|
||||
integrations, references, skills, and tools; intercept model requests and tool
|
||||
execution; and call a subset of the V2 client.
|
||||
|
||||
<Callout type="warning">
|
||||
The V2 plugin API is beta. Entrypoints, hooks, draft shapes, and configuration may change before the stable release.
|
||||
Use the `/v2` exports described on this page.
|
||||
</Callout>
|
||||
|
||||
## Load plugins
|
||||
|
||||
Plugins can be loaded from npm packages, explicit local paths, or config
|
||||
directories. Each module must have one default export containing a unique
|
||||
plugin `id` and a `setup` function.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add ordered entries to the `plugins` field in `opencode.json(c)`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugins": [
|
||||
"opencode-acme-plugin@1.2.0",
|
||||
"@acme/opencode-plugin",
|
||||
"./plugins/local.ts",
|
||||
{
|
||||
"package": "./plugins/reviewer.ts",
|
||||
"options": {
|
||||
"agent": "reviewer",
|
||||
"strict": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
A string is either a package specifier or a local path. Local paths must start
|
||||
with `./` or `../` and resolve relative to the configuration file containing
|
||||
the entry. Absolute paths and `file://` URLs are also supported. Both scoped
|
||||
packages and versioned package specifiers are supported.
|
||||
|
||||
Use the object form to pass JSON configuration to the plugin. OpenCode passes
|
||||
`options` unchanged as `ctx.options`; omitted options become an empty object.
|
||||
The plugin owns validation and defaults for its options.
|
||||
|
||||
See [Config](/config#locations) for configuration locations and precedence.
|
||||
Entries from all applicable files are processed from lowest to highest
|
||||
precedence rather than replacing the entire array.
|
||||
|
||||
### Local discovery
|
||||
|
||||
OpenCode automatically scans this directory in every discovered OpenCode config
|
||||
directory:
|
||||
|
||||
```text
|
||||
.opencode/plugins/
|
||||
```
|
||||
|
||||
The equivalent global directory is `~/.config/opencode/plugins/`. Direct `.ts`
|
||||
and `.js` children are loaded. An immediate child directory is also loaded as a
|
||||
package when OpenCode can resolve a string `exports`, `module`, or `main`
|
||||
entrypoint, or an `index.ts` or `index.js` file.
|
||||
|
||||
A `plugins/` directory beside a project-root `opencode.json` is not discovered
|
||||
automatically. Put it under `.opencode/`, or add its file explicitly with a
|
||||
relative config entry.
|
||||
|
||||
### Enable and disable
|
||||
|
||||
A string beginning with `-` disables plugins by their exported `id`. `*`
|
||||
matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
|
||||
applied in order:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"plugins": ["./plugins/reviewer.ts", "-acme.reviewer", "-opencode.provider.*", "opencode.provider.openai"],
|
||||
}
|
||||
```
|
||||
|
||||
Package specifiers and local paths locate plugin modules; they are not disable
|
||||
selectors. Use the `id` from the plugin's default export to disable it. A later
|
||||
ID entry re-enables a loaded or built-in plugin. Explicit config directives run
|
||||
after local auto-discovery, so they can disable discovered plugins by ID.
|
||||
|
||||
User plugins are activated in configured order between OpenCode's internal
|
||||
plugin phases. Hooks run sequentially in registration order, and later hooks
|
||||
observe earlier mutations. Do not depend on the internal phase ordering while
|
||||
the API is beta.
|
||||
|
||||
### Installation and dependencies
|
||||
|
||||
OpenCode installs bare package entries and their production dependencies into
|
||||
an isolated cache. Package installation does not run lifecycle scripts.
|
||||
Published packages should expose their plugin entrypoint and include every
|
||||
runtime import in `dependencies`.
|
||||
|
||||
Install a package plugin globally with the CLI:
|
||||
|
||||
```sh
|
||||
opencode2 plugin add opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
This installs and inspects the package before changing configuration. Packages
|
||||
with a server entrypoint are added to global `opencode.json(c)`. Packages that
|
||||
only expose `./tui` are added to global `cli.json` instead.
|
||||
|
||||
The command accepts npm registry package names with an optional version,
|
||||
dist-tag, or semver range. Configure local paths directly instead; Git, tarball,
|
||||
and npm alias targets are not accepted by `plugin add`.
|
||||
|
||||
List configured and active plugins, or remove a package from both global server
|
||||
and TUI configuration:
|
||||
|
||||
```sh
|
||||
opencode2 plugin list
|
||||
opencode2 plugin list --builtin
|
||||
opencode2 plugin remove opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
Built-in server plugins are hidden from the default list. Removing a plugin
|
||||
keeps its package cache available for later reuse.
|
||||
|
||||
Local files and local package directories are imported directly. OpenCode does
|
||||
**not** install their dependencies. Install dependencies in a `package.json`
|
||||
visible from the plugin file, for example:
|
||||
|
||||
```sh
|
||||
cd .opencode
|
||||
bun add @opencode-ai/plugin@beta
|
||||
```
|
||||
|
||||
Match the plugin package version to the OpenCode release you target.
|
||||
|
||||
Configuration and discovered plugin files under watched config directories are
|
||||
reloaded when they change. Reloading replaces the active plugin generation and
|
||||
releases its scoped registrations. Restart OpenCode after changing an npm
|
||||
package version or a local dependency when no watched file changed.
|
||||
|
||||
## Create a plugin
|
||||
|
||||
Export the result of `Plugin.define` as the module default:
|
||||
|
||||
```ts title=".opencode/plugins/reviewer.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer",
|
||||
setup: async (ctx) => {
|
||||
const description =
|
||||
typeof ctx.options.description === "string" ? ctx.options.description : "Reviews code for regressions"
|
||||
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = description
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`setup` runs each time the plugin is activated. Register long-lived behavior
|
||||
during setup; do not wait there on an infinite event stream. It may return a
|
||||
synchronous or asynchronous cleanup function. OpenCode awaits that cleanup
|
||||
when the plugin is disabled, reloaded, or shut down:
|
||||
|
||||
```ts
|
||||
setup: async (ctx) => {
|
||||
const controller = new AbortController()
|
||||
const task = synchronize(ctx, controller.signal)
|
||||
|
||||
return async () => {
|
||||
controller.abort()
|
||||
await task
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hook registrations are released automatically with the same plugin scope. Use
|
||||
the returned cleanup for resources the plugin owns, such as timers, watchers,
|
||||
connections, and background tasks.
|
||||
|
||||
### Context
|
||||
|
||||
The plugin context is essentially an [OpenCode server client](/build/client).
|
||||
Its read and action methods use the same inputs and responses as the client. It
|
||||
adds plugin-only methods for transforms, runtime hooks, reloads, registrations,
|
||||
and plugin options.
|
||||
|
||||
| Capability | Available operations |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `ctx.agent` | `list`, `get`, `transform`, `reload` |
|
||||
| `ctx.catalog.provider` | `list`, `get` |
|
||||
| `ctx.catalog.model` | `list`, `get`, `default` |
|
||||
| `ctx.catalog` | `transform`, `reload` |
|
||||
| `ctx.command` | `list`, `transform`, `reload` |
|
||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
|
||||
| `ctx.skill` | `list`, `transform`, `reload` |
|
||||
| `ctx.tool` | `transform` and `hook` |
|
||||
| `ctx.aisdk` | `hook` |
|
||||
| `ctx.event` | `subscribe` to the current public server event stream |
|
||||
| `ctx.options` | Readonly options from the matching config object |
|
||||
|
||||
### Transform hooks
|
||||
|
||||
Transform hooks let a plugin modify how OpenCode is configured. Use them to add
|
||||
or remove definitions, override settings, choose defaults, and provide tools or
|
||||
other sources.
|
||||
|
||||
| Transform | Draft operations |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `agent.transform` | `list`, `get`, `default`, `update`, `remove` |
|
||||
| `catalog.transform` | Provider `list`, `get`, `update`, `remove`; model `get`, `update`, `remove`; default model `get`, `set` |
|
||||
| `command.transform` | `list`, `get`, `update`, `remove` |
|
||||
| `integration.transform` | Integration `list`, `get`, `update`, `remove`; method `list`, `update`, `remove` |
|
||||
| `reference.transform` | `add`, `remove`, `list` |
|
||||
| `skill.transform` | `source`, `list` |
|
||||
| `tool.transform` | `add` |
|
||||
|
||||
Here's an example that keeps models synced from a remote source:
|
||||
|
||||
```js title=".opencode/plugins/remote-models.js"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.remote-models",
|
||||
setup: async (ctx) => {
|
||||
let models = []
|
||||
|
||||
await ctx.catalog.transform((catalog) => {
|
||||
for (const model of models) {
|
||||
catalog.model.update(model.providerID, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const response = await fetch("https://example.com/opencode/models.json", {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!response.ok) return
|
||||
models = await response.json()
|
||||
await ctx.catalog.reload()
|
||||
}
|
||||
|
||||
await refresh()
|
||||
const timer = setInterval(() => void refresh().catch(console.error), 60_000)
|
||||
return () => clearInterval(timer)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`ctx.catalog.reload()` replays every catalog transform to derive the new
|
||||
catalog. Each plugin's logic remains composed with the others, so a later
|
||||
plugin can still modify models added by an earlier one. The catalog updates
|
||||
without restarting OpenCode.
|
||||
|
||||
### Runtime hooks
|
||||
|
||||
Runtime hooks intercept live operations:
|
||||
|
||||
| Hook | Mutable fields |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch |
|
||||
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
|
||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||
|
||||
HTTP hooks can modify requests and responses. They apply to native models; AI
|
||||
SDK models do not currently pass through these hooks. Request and response
|
||||
bodies are one-shot streams. Use `clone()` when you intentionally need a
|
||||
separate reader, but be aware that its slower branch may buffer data. To inspect
|
||||
or modify chunks while preserving streaming, replace the body with one piped
|
||||
through a `TransformStream`.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("http.request", (event) => {
|
||||
event.request.headers.set("x-session-id", event.sessionID)
|
||||
})
|
||||
|
||||
await ctx.session.hook("http.response", (event) => {
|
||||
event.response = new Response(event.response.body, {
|
||||
status: event.response.status,
|
||||
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
For example, remove a tool from selected model requests and normalize another
|
||||
tool's input:
|
||||
|
||||
```ts title=".opencode/plugins/guards.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.guards",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("context", (event) => {
|
||||
delete event.tools.write
|
||||
})
|
||||
|
||||
await ctx.tool.hook("execute.before", (event) => {
|
||||
if (event.tool !== "lookup" || typeof event.input !== "object" || event.input === null) return
|
||||
event.input = { ...event.input, source: "plugin" }
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
A hook failure fails the operation it intercepts. Keep runtime hooks fast and
|
||||
handle expected errors inside the callback.
|
||||
|
||||
## Examples
|
||||
|
||||
### Add a tool
|
||||
|
||||
Register a structural tool definition with a name and registration options.
|
||||
Define its input with JSON Schema and use an async executor:
|
||||
|
||||
```js title=".opencode/plugins/greeting.js"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.greeting",
|
||||
setup: async (ctx) => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add("greeting", {
|
||||
description: "Create a greeting",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: {
|
||||
type: "object",
|
||||
properties: { greeting: { type: "string" } },
|
||||
required: ["greeting"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
const text = `Hello, ${name}!`
|
||||
return {
|
||||
output: { greeting: text },
|
||||
content: text,
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Unsupported characters in tool names are normalized to underscores. Namespace
|
||||
segments must begin with a letter, contain at most 64 letters, digits,
|
||||
underscores, or hyphens, and are joined with dots. Pass the optional third
|
||||
argument to `tools.add` to configure the registration with
|
||||
`{ namespace, codemode }`:
|
||||
|
||||
- `namespace` prefixes and groups the exposed tool name.
|
||||
- `codemode` defaults to `true` and makes the tool available through the
|
||||
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
|
||||
provider.
|
||||
|
||||
The executor receives a second context argument containing `id`, `sessionID`,
|
||||
`agent`, `messageID`, and `progress`. A tool with `output`
|
||||
must return `output`; Effect and Standard Schema codecs validate it, while raw
|
||||
JSON Schema definitions enforce JSON compatibility only. A tool
|
||||
without `output` returns model-visible `content` instead.
|
||||
|
||||
### Add a command
|
||||
|
||||
```js title=".opencode/plugins/review-command.js"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.review-command",
|
||||
setup: async (ctx) => {
|
||||
await ctx.command.transform((commands) => {
|
||||
commands.update("review", (command) => {
|
||||
command.description = "Review the current changes"
|
||||
command.template = "Review the current changes for correctness and missing tests."
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Set the default model
|
||||
|
||||
```js title=".opencode/plugins/default-model.js"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.default-model",
|
||||
setup: async (ctx) => {
|
||||
await ctx.catalog.transform((catalog) => {
|
||||
catalog.model.default.set("anthropic", "claude-sonnet-4-5")
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Publish a package
|
||||
|
||||
A package plugin uses the same default export as a local plugin. A minimal
|
||||
manifest is:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tui": "./src/tui.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "beta"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Packages with a TUI entrypoint should set `tui: true` on their server plugin
|
||||
definition. A locally connected TUI loads the package's `./tui` export from the
|
||||
existing OpenCode package cache. A TUI connected to a remote server skips it
|
||||
when that package is not installed locally.
|
||||
|
||||
Use versions compatible with the OpenCode release you target and test the
|
||||
installed package, not only a workspace-linked copy. Because the plugin API is
|
||||
beta, publish compatible plugin updates when V2 entrypoints or contracts
|
||||
change.
|
||||
|
||||
## Verify loading
|
||||
|
||||
List active plugin IDs through the V2 API:
|
||||
|
||||
```sh
|
||||
opencode2 api get /api/plugin
|
||||
```
|
||||
|
||||
If a plugin is absent, check the server log described in
|
||||
[Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are
|
||||
logged; one failing package does not prevent unrelated valid packages from
|
||||
being resolved.
|
||||
|
||||
## Effect
|
||||
|
||||
OpenCode provides a first-class Effect API for plugins through the
|
||||
`@opencode-ai/plugin/effect` entrypoint. Install `effect` alongside the
|
||||
plugin package and export an `effect` function instead of `setup`:
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/plugin@beta effect
|
||||
```
|
||||
|
||||
```ts title=".opencode/plugins/reviewer-effect.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer-effect",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = "Reviews code for regressions"
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Context operations return Effects. The plugin effect is scoped, so finalizers,
|
||||
fibers, and registrations are released when the plugin reloads or unloads.
|
||||
OpenCode does not expose its private Core services to the plugin; use the
|
||||
capabilities on `ctx`.
|
||||
|
||||
Typed tools can use `Schema` from `effect`. Effect and Promise plugins use the
|
||||
same `tools.add(name, tool, options?)` registration shape. Effect executors
|
||||
return an Effect and may fail with the typed tool failure channel.
|
||||
@@ -0,0 +1,454 @@
|
||||
---
|
||||
title: "CLI"
|
||||
---
|
||||
|
||||
CLI plugins extend the terminal with commands, routes, slots, Markdown renderers, notifications, and local state.
|
||||
|
||||
```ts title="src/tui.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.cli",
|
||||
setup(context) {
|
||||
context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
`setup` receives configuration, app metadata, the current location, the OpenCode client, cached data, theme tokens, the
|
||||
OpenTUI renderer, and the UI APIs documented below.
|
||||
|
||||
```ts
|
||||
setup(context) {
|
||||
const compact = context.options.compact === true
|
||||
const location = context.location ?? context.data.location.default()
|
||||
const version = context.app.version
|
||||
const channel = context.app.channel
|
||||
const client = context.client
|
||||
const renderer = context.renderer
|
||||
const theme = context.theme
|
||||
}
|
||||
```
|
||||
|
||||
Return a cleanup function for resources owned by the plugin.
|
||||
|
||||
```ts
|
||||
setup(context) {
|
||||
const stop = context.data.on("session.execution.succeeded", () => {})
|
||||
return () => stop()
|
||||
}
|
||||
```
|
||||
|
||||
## Client
|
||||
|
||||
`context.client` is the generated OpenCode client and can call the connected server, including a remote server.
|
||||
|
||||
```ts
|
||||
const response = await context.client.plugin.list({
|
||||
location: context.location ?? context.data.location.default(),
|
||||
})
|
||||
const plugins = response.data
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
Use `data.on` for one typed event or `data.listen` for every server event; both return an unsubscribe function.
|
||||
|
||||
```ts
|
||||
const stopPermission = context.data.on("permission.asked", (event) => {
|
||||
context.ui.toast.show({ message: `Permission ${event.data.id}` })
|
||||
})
|
||||
const stopAll = context.data.listen(({ details }) => console.log(details.type))
|
||||
|
||||
return () => {
|
||||
stopPermission()
|
||||
stopAll()
|
||||
}
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
Session data exposes list, lookup, hierarchy, cost, status, synchronization, and invalidation.
|
||||
|
||||
```ts
|
||||
const sessions = context.data.session.list()
|
||||
const session = context.data.session.get(sessionID)
|
||||
const rootID = context.data.session.root(sessionID)
|
||||
const familyIDs = context.data.session.family(sessionID)
|
||||
const cost = context.data.session.cost(sessionID)
|
||||
const status = context.data.session.status(sessionID)
|
||||
await context.data.session.sync(sessionID)
|
||||
context.data.session.invalidate(sessionID)
|
||||
```
|
||||
|
||||
Pending inbox items and messages have list, lookup, sync, and invalidate APIs.
|
||||
|
||||
```ts
|
||||
await context.data.session.pending.sync(sessionID)
|
||||
const pending = context.data.session.pending.list(sessionID)
|
||||
context.data.session.pending.invalidate(sessionID)
|
||||
|
||||
await context.data.session.message.sync(sessionID)
|
||||
const messages = context.data.session.message.list(sessionID)
|
||||
const message = context.data.session.message.get(sessionID, messageID)
|
||||
context.data.session.message.invalidate(sessionID)
|
||||
```
|
||||
|
||||
Permission requests can be read and refreshed for a session.
|
||||
|
||||
```ts
|
||||
await context.data.session.permission.sync(sessionID)
|
||||
const requests = context.data.session.permission.list(sessionID) ?? []
|
||||
context.data.session.permission.invalidate(sessionID)
|
||||
```
|
||||
|
||||
Forms can be listed, refreshed, replied to, or cancelled at a location.
|
||||
|
||||
```ts
|
||||
import type { FormCancelInput, FormReplyInput } from "@opencode-ai/client"
|
||||
|
||||
async function handleForm(reply: FormReplyInput, cancel: FormCancelInput) {
|
||||
const location = context.location
|
||||
await context.data.session.form.sync(sessionID, location)
|
||||
const forms = context.data.session.form.list(sessionID, location) ?? []
|
||||
await context.data.session.form.reply(reply, location)
|
||||
await context.data.session.form.cancel(cancel, location)
|
||||
context.data.session.form.invalidate(sessionID, location)
|
||||
}
|
||||
```
|
||||
|
||||
## Projects and shells
|
||||
|
||||
Projects and saved permissions support list, lookup, sync, and invalidate operations.
|
||||
|
||||
```ts
|
||||
await context.data.project.sync()
|
||||
const projects = context.data.project.list()
|
||||
const project = context.data.project.get(projectID)
|
||||
context.data.project.invalidate()
|
||||
|
||||
await context.data.project.permission.sync(projectID)
|
||||
const saved = context.data.project.permission.list(projectID) ?? []
|
||||
context.data.project.permission.invalidate(projectID)
|
||||
```
|
||||
|
||||
Shell data supports location-scoped list, lookup, sync, and invalidate operations.
|
||||
|
||||
```ts
|
||||
await context.data.shell.sync(context.location)
|
||||
const shells = context.data.shell.list(context.location)
|
||||
const shell = context.data.shell.get(shellID)
|
||||
context.data.shell.invalidate(context.location)
|
||||
```
|
||||
|
||||
## Location data
|
||||
|
||||
Location state exposes the default location and refresh controls.
|
||||
|
||||
```ts
|
||||
const location = context.data.location.default()
|
||||
await context.data.location.sync(location)
|
||||
context.data.location.invalidate(location)
|
||||
```
|
||||
|
||||
Version-control state exposes repository information at a location.
|
||||
|
||||
```ts
|
||||
await context.data.location.vcs.sync(context.location)
|
||||
const vcs = context.data.location.vcs.info(context.location)
|
||||
const branch = vcs?.branch.current
|
||||
context.data.location.vcs.invalidate(context.location)
|
||||
```
|
||||
|
||||
Agents, commands, integrations, models, providers, references, skills, and MCP data share `list`, `sync`, and
|
||||
`invalidate` methods.
|
||||
|
||||
```ts
|
||||
const location = context.location
|
||||
await Promise.all([
|
||||
context.data.location.agent.sync(location),
|
||||
context.data.location.command.sync(location),
|
||||
context.data.location.integration.sync(location),
|
||||
context.data.location.model.sync(location),
|
||||
context.data.location.provider.sync(location),
|
||||
context.data.location.reference.sync(location),
|
||||
context.data.location.skill.sync(location),
|
||||
context.data.location.mcp.server.sync(location),
|
||||
context.data.location.mcp.resource.sync(location),
|
||||
])
|
||||
|
||||
const agents = context.data.location.agent.list(location) ?? []
|
||||
const commands = context.data.location.command.list(location) ?? []
|
||||
const integrations = context.data.location.integration.list(location) ?? []
|
||||
const models = context.data.location.model.list(location) ?? []
|
||||
const providers = context.data.location.provider.list(location) ?? []
|
||||
const references = context.data.location.reference.list(location) ?? []
|
||||
const skills = context.data.location.skill.list(location) ?? []
|
||||
const servers = context.data.location.mcp.server.list(location) ?? []
|
||||
const resources = context.data.location.mcp.resource.list(location) ?? []
|
||||
|
||||
context.data.location.model.invalidate(location)
|
||||
```
|
||||
|
||||
## Attention
|
||||
|
||||
Attention requests can show a system notification, play a configured sound, or do both based on terminal focus.
|
||||
|
||||
```ts
|
||||
const result = await context.attention.notify({
|
||||
title: "OpenCode",
|
||||
message: "Session done",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "done", volume: 0.5, when: "always" },
|
||||
})
|
||||
console.log(result.ok, result.notification, result.sound, result.skipped)
|
||||
```
|
||||
|
||||
## Theme and renderer
|
||||
|
||||
Use semantic theme tokens with OpenTUI elements and pass `context.renderer` to renderer-specific helpers.
|
||||
|
||||
```tsx
|
||||
const Status = () => <text fg={context.theme.text.default}>Ready</text>
|
||||
const renderer = context.renderer
|
||||
```
|
||||
|
||||
## Solid components
|
||||
|
||||
Use `usePlugin` to access the current context inside JSX rendered by a route, dialog, or slot.
|
||||
|
||||
```tsx
|
||||
import { usePlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
function Status() {
|
||||
const context = usePlugin()
|
||||
return <text fg={context.theme.text.default}>{context.app.version}</text>
|
||||
}
|
||||
```
|
||||
|
||||
## Markdown
|
||||
|
||||
Register a fenced-code renderer by language; the returned function unregisters it.
|
||||
|
||||
```ts
|
||||
const unregister = context.markdown.registerCodeBlockRenderer(
|
||||
"acme",
|
||||
(_token, render) => render.defaultRender(),
|
||||
)
|
||||
return unregister
|
||||
```
|
||||
|
||||
## Commands and keymaps
|
||||
|
||||
Register palette, slash, and keyboard commands in a reactive keymap layer.
|
||||
|
||||
```ts
|
||||
context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
priority: 10,
|
||||
commands: [
|
||||
{
|
||||
id: "acme.status",
|
||||
title: "Show Acme status",
|
||||
group: "Acme",
|
||||
bind: "ctrl+g",
|
||||
palette: true,
|
||||
slash: { name: "acme", aliases: ["status"], arguments: true },
|
||||
enabled: () => true,
|
||||
suggested: true,
|
||||
run: async (input) => context.ui.toast.show({ message: input ?? "Ready" }),
|
||||
},
|
||||
],
|
||||
bindings: ["acme.status"],
|
||||
}))
|
||||
```
|
||||
|
||||
A layer may target one OpenTUI renderable and can return `false` from a command to continue keyboard dispatch.
|
||||
|
||||
```ts
|
||||
context.keymap.layer(() => ({
|
||||
target: () => panel,
|
||||
commands: [{ bind: "escape", run: (_input, event) => (event ? false : undefined) }],
|
||||
}))
|
||||
```
|
||||
|
||||
Dispatch commands, inspect shortcuts and command state, or push a temporary input mode.
|
||||
|
||||
```ts
|
||||
context.keymap.dispatch("acme.status", "verbose")
|
||||
const shortcuts = context.keymap.shortcuts("acme.status")
|
||||
const commands = context.keymap.commands()
|
||||
const pending = context.keymap.pending()
|
||||
const active = context.keymap.active()
|
||||
const currentMode = context.keymap.mode.current()
|
||||
const popMode = context.keymap.mode.push("acme-search")
|
||||
popMode()
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
Durable storage persists JSON across restarts and synchronizes across TUI instances.
|
||||
|
||||
```ts
|
||||
const [settings, updateSettings] = context.storage.store("settings", {
|
||||
initial: { compact: false },
|
||||
})
|
||||
await updateSettings((draft) => {
|
||||
draft.compact = true
|
||||
})
|
||||
```
|
||||
|
||||
Memory storage survives plugin reloads but is discarded when the TUI exits.
|
||||
|
||||
```ts
|
||||
const [state, updateState] = context.storage.memory("state", {
|
||||
initial: { count: 0 },
|
||||
})
|
||||
updateState((draft) => {
|
||||
draft.count++
|
||||
})
|
||||
```
|
||||
|
||||
## Dialogs and toasts
|
||||
|
||||
Use promise-based dialogs for alerts, confirmations, text input, and selection.
|
||||
|
||||
```ts
|
||||
await context.ui.dialog.alert({ title: "Acme", message: "Ready" })
|
||||
const confirmed = await context.ui.dialog.confirm({
|
||||
title: "Continue?",
|
||||
message: "Run the Acme action?",
|
||||
label: { confirm: "Run", cancel: "Cancel" },
|
||||
})
|
||||
const name = await context.ui.dialog.prompt({ title: "Name", placeholder: "release" })
|
||||
const mode = await context.ui.dialog.select({
|
||||
title: "Mode",
|
||||
current: "safe",
|
||||
options: [
|
||||
{ title: "Safe", value: "safe", description: "Ask before changes" },
|
||||
{ title: "Fast", value: "fast", disabled: false, category: "Advanced" },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
Custom JSX dialogs can set their size and close themselves.
|
||||
|
||||
```tsx
|
||||
context.ui.dialog.set({ size: "large", centered: true })
|
||||
context.ui.dialog.show(() => <box><text>Acme</text></box>, () => console.log("closed"))
|
||||
context.ui.dialog.clear()
|
||||
```
|
||||
|
||||
Toasts support title, message, variant, and duration.
|
||||
|
||||
```ts
|
||||
context.ui.toast.show({
|
||||
title: "Acme",
|
||||
message: "Saved",
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
})
|
||||
```
|
||||
|
||||
## Routes and tabs
|
||||
|
||||
Register a JSX route, inspect the current route, and navigate to home, a session, or the plugin page.
|
||||
|
||||
```tsx
|
||||
const unregister = context.ui.router.register({
|
||||
name: "dashboard",
|
||||
render: ({ data }) => <text>{String(data?.title ?? "Acme")}</text>,
|
||||
})
|
||||
const current = context.ui.router.current()
|
||||
context.ui.router.navigate({ type: "plugin", name: "dashboard", data: { title: "Status" } })
|
||||
context.ui.router.navigate({ type: "session", sessionID })
|
||||
context.ui.router.navigate({ type: "home" })
|
||||
return unregister
|
||||
```
|
||||
|
||||
Tabs can be listed, opened, focused, and closed when session tabs are enabled.
|
||||
|
||||
```ts
|
||||
if (context.ui.tabs.enabled()) {
|
||||
context.ui.tabs.open(sessionID)
|
||||
const tabs = context.ui.tabs.list()
|
||||
context.ui.tabs.focus(sessionID)
|
||||
context.ui.tabs.close(sessionID)
|
||||
context.ui.tabs.close()
|
||||
}
|
||||
```
|
||||
|
||||
## Slots
|
||||
|
||||
Slots insert or replace JSX at `app`, `home.footer`, `prompt.footer`, `prompt.footer.status`, `prompt.footer.file`,
|
||||
`session.composer.top`, `sidebar.content`, or `sidebar.footer`.
|
||||
|
||||
```tsx
|
||||
return context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
render: ({ sessionID }) => <text>{context.data.session.get(sessionID)?.title}</text>,
|
||||
})
|
||||
```
|
||||
|
||||
Use `prepend`, `append`, `before`, `after`, or `replace` for placement.
|
||||
|
||||
```tsx
|
||||
context.ui.slot({ prepend: "home.footer", render: () => <text>Before footer content</text> })
|
||||
context.ui.slot({ append: "home.footer", render: () => <text>After footer content</text> })
|
||||
context.ui.slot({ before: "home.footer", render: () => <text>Before footer slot</text> })
|
||||
context.ui.slot({ after: "home.footer", render: () => <text>After footer slot</text> })
|
||||
context.ui.slot({ replace: "home.footer", render: () => <text>New footer</text> })
|
||||
```
|
||||
|
||||
## Formatting
|
||||
|
||||
Format filesystem paths for display, including home-directory abbreviation.
|
||||
|
||||
```ts
|
||||
const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project")
|
||||
```
|
||||
|
||||
## Publish and load
|
||||
|
||||
Expose the CLI plugin through `./tui`; add OpenTUI peers when the plugin renders JSX.
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "opencode-acme-plugin",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tui": "./src/tui.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "beta"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.5.8",
|
||||
"@opentui/solid": ">=0.5.8",
|
||||
"solid-js": ">=1.9.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Set `tui: true` on the [main plugin](/build/plugins) for automatic loading.
|
||||
|
||||
```ts title="src/index.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.server",
|
||||
tui: true,
|
||||
setup() {},
|
||||
})
|
||||
```
|
||||
|
||||
Configure a CLI-only package in [`cli.json`](/cli/plugins) so it remains active against remote servers.
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"plugins": ["opencode-acme-plugin"]
|
||||
}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,148 +0,0 @@
|
||||
---
|
||||
title: "SDK"
|
||||
---
|
||||
|
||||
`@opencode-ai/sdk` hosts OpenCode directly inside your application. Unlike the
|
||||
[network client](/build/client), it assembles the OpenCode server and routes API
|
||||
calls through its HTTP router in memory. It opens no HTTP listener and adds no
|
||||
network hop between the client and server.
|
||||
|
||||
<Callout type="warning">
|
||||
The V2 SDK is beta. Install the current preview with `bun add @opencode-ai/sdk@dev`; its API may change before a
|
||||
stable release.
|
||||
</Callout>
|
||||
|
||||
## Create a host
|
||||
|
||||
`OpenCode.create()` returns an explicitly owned host. Use `await using` to
|
||||
release its router, Location services, fibers, and scoped plugin registrations:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk"
|
||||
|
||||
await using opencode = await OpenCode.create()
|
||||
const session = await opencode.sessions.create({
|
||||
location: { directory: "/workspace" },
|
||||
})
|
||||
|
||||
await opencode.sessions.prompt({
|
||||
sessionID: session.id,
|
||||
text: "Review the current changes",
|
||||
})
|
||||
```
|
||||
|
||||
Call `await opencode.close()` explicitly when explicit resource management is
|
||||
not available.
|
||||
|
||||
The embedded host uses the same Promise values, declared errors, request
|
||||
options, and `AsyncIterable` streams as `@opencode-ai/client`. It exposes the
|
||||
full generated client and adds the convenience aliases `sessions` and `events`
|
||||
for the session and event groups.
|
||||
|
||||
## Stream events
|
||||
|
||||
```ts
|
||||
for await (const event of opencode.events.subscribe()) {
|
||||
console.log(event.type)
|
||||
}
|
||||
```
|
||||
|
||||
Pass an `AbortSignal` through the generated request options, or leave an
|
||||
iteration to cancel its response body.
|
||||
|
||||
## Register plugins
|
||||
|
||||
Pass initial Promise plugins to `OpenCode.create()`. Embedded plugins use the
|
||||
same discovery and Location-scoped activation path as configured plugins.
|
||||
|
||||
```ts
|
||||
const plugin = {
|
||||
id: "example",
|
||||
async setup(ctx) {
|
||||
await ctx.agent.transform((agents) => {
|
||||
// Modify the Location's agent catalog.
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
await using opencode = await OpenCode.create({ plugins: [plugin] })
|
||||
```
|
||||
|
||||
Call `await opencode.plugin(plugin)` to register another plugin after startup.
|
||||
|
||||
See the [Plugins guide](/build/plugins) for the plugin context and available
|
||||
hooks.
|
||||
|
||||
## Workerd
|
||||
|
||||
Use `@opencode-ai/sdk/workerd` inside a Cloudflare Durable Object. This profile
|
||||
uses the object's SQLite storage, persists durable events for eviction recovery,
|
||||
and replaces unavailable local filesystem and process services.
|
||||
|
||||
Hold one host for the lifetime of the Durable Object instance instead of
|
||||
creating one for every request:
|
||||
|
||||
```ts
|
||||
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
|
||||
import myPlugin from "./my-plugin"
|
||||
|
||||
export class OpenCodeDO {
|
||||
private readonly opencode: Promise<OpenCodeWorkerd.Interface>
|
||||
|
||||
constructor(state: DurableObjectState) {
|
||||
this.opencode = state.blockConcurrencyWhile(() =>
|
||||
OpenCodeWorkerd.create({
|
||||
storage: state.storage,
|
||||
config: { default_agent: "build" },
|
||||
plugins: [myPlugin],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fetch() {
|
||||
const opencode = await this.opencode
|
||||
return Response.json(await opencode.health.get())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`blockConcurrencyWhile` keeps every Durable Object event out until the host is
|
||||
ready and resets the object if initialization fails. The retained Promise gives
|
||||
request handlers direct access to the same host after startup. Configuration is
|
||||
a typed JavaScript object, and plugins are imported values bundled with the
|
||||
Worker.
|
||||
|
||||
Wrangler selects OpenCode's Workerd-safe low-level implementations through the
|
||||
`workerd` package condition. Cloudflare may evict a Durable Object without
|
||||
running cleanup, so correctness does not depend on `close()` being called.
|
||||
|
||||
## Effect
|
||||
|
||||
Import the Effect-native API from `@opencode-ai/sdk/effect`. Closing its Effect
|
||||
Scope releases the embedded host:
|
||||
|
||||
```ts
|
||||
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/sdk/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.create()
|
||||
return yield* opencode.sessions.create({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const session = await Effect.runPromise(program)
|
||||
```
|
||||
|
||||
Effect applications can contribute plugins from ordinary registration layers.
|
||||
The registration layer may depend on `OpenCode.Service` and any other services
|
||||
needed to construct the plugin; `OpenCode.layer()` remains unaware of those
|
||||
features.
|
||||
|
||||
Use `OpenCode.layer()` for dependency injection. The Effect-native Workerd
|
||||
entrypoint is `@opencode-ai/sdk/workerd/effect`.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: "Cloudflare"
|
||||
---
|
||||
|
||||
Use `@opencode-ai/sdk/workerd` inside a Cloudflare Durable Object. This profile uses the object's SQLite storage,
|
||||
persists durable events for eviction recovery, and replaces unavailable local filesystem and process services.
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/sdk@dev
|
||||
```
|
||||
|
||||
Hold one host for the lifetime of the Durable Object instance instead of creating one for every request.
|
||||
|
||||
```ts
|
||||
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
|
||||
import myPlugin from "./my-plugin"
|
||||
|
||||
export class OpenCodeDO {
|
||||
private readonly opencode: Promise<OpenCodeWorkerd.Interface>
|
||||
|
||||
constructor(state: DurableObjectState) {
|
||||
this.opencode = state.blockConcurrencyWhile(() =>
|
||||
OpenCodeWorkerd.create({
|
||||
storage: state.storage,
|
||||
config: { default_agent: "build" },
|
||||
plugins: [myPlugin],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fetch() {
|
||||
const opencode = await this.opencode
|
||||
return Response.json(await opencode.health.get())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`blockConcurrencyWhile` keeps every Durable Object event out until the host is ready and resets the object if
|
||||
initialization fails. The retained Promise gives request handlers direct access to the same host after startup.
|
||||
|
||||
```ts
|
||||
constructor(state: DurableObjectState) {
|
||||
this.opencode = state.blockConcurrencyWhile(() =>
|
||||
OpenCodeWorkerd.create({ storage: state.storage }),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Configuration is a typed JavaScript object, and plugins are imported values bundled with the Worker.
|
||||
|
||||
```ts
|
||||
await OpenCodeWorkerd.create({
|
||||
storage: state.storage,
|
||||
config: { default_agent: "build" },
|
||||
plugins: [myPlugin],
|
||||
})
|
||||
```
|
||||
|
||||
Wrangler selects OpenCode's Workerd-safe implementations through the `workerd` package condition. Cloudflare may evict
|
||||
a Durable Object without running cleanup, so correctness does not depend on `close()` being called.
|
||||
|
||||
```jsonc title="wrangler.jsonc"
|
||||
{
|
||||
"compatibility_flags": ["nodejs_compat"]
|
||||
}
|
||||
```
|
||||
|
||||
## Effect
|
||||
|
||||
Use the Effect-native entrypoint from `@opencode-ai/sdk/workerd/effect`.
|
||||
|
||||
```ts
|
||||
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
return yield* OpenCodeWorkerd.create({ storage: state.storage })
|
||||
}),
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: "Effect"
|
||||
---
|
||||
|
||||
`@opencode-ai/sdk/effect` is the Effect-native embedded SDK. Operations return typed Effects and Streams, and closing
|
||||
the owning Scope releases the router, Location services, fibers, and plugin registrations.
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/sdk@dev effect
|
||||
```
|
||||
|
||||
## Create a host
|
||||
|
||||
Create the host inside `Effect.scoped`, then use its generated API groups or the `sessions` alias.
|
||||
|
||||
```ts
|
||||
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/sdk/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.create()
|
||||
const session = yield* opencode.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
|
||||
})
|
||||
yield* opencode.sessions.prompt({
|
||||
sessionID: session.id,
|
||||
text: "Review the current changes",
|
||||
})
|
||||
return session
|
||||
}),
|
||||
)
|
||||
|
||||
const session = await Effect.runPromise(program)
|
||||
```
|
||||
|
||||
The embedded host uses the same schema values, declared errors, request options, and Streams as
|
||||
`@opencode-ai/client/effect`.
|
||||
|
||||
```ts
|
||||
const health = yield* opencode.health.get()
|
||||
const sessions = yield* opencode.sessions.list()
|
||||
```
|
||||
|
||||
## Stream events
|
||||
|
||||
Streaming endpoints return Effect `Stream` values. Fork consumers in the host Scope when they should run in the
|
||||
background.
|
||||
|
||||
```ts
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
yield* opencode.events.subscribe().pipe(
|
||||
Stream.runForEach((event) => Effect.logInfo("OpenCode event", { type: event.type })),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
```
|
||||
|
||||
## Register plugins
|
||||
|
||||
Register Effect plugins through the embedded host. Their registrations remain scoped to the host.
|
||||
|
||||
```ts
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const plugin = Plugin.define({
|
||||
id: "example",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const storage = ctx.storage
|
||||
yield* storage.set("embedded", true)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* opencode.plugin(plugin)
|
||||
```
|
||||
|
||||
See the [Effect plugins guide](/build/plugins/effect) for the complete plugin context.
|
||||
|
||||
```ts
|
||||
yield* opencode.plugin.list()
|
||||
```
|
||||
|
||||
## Layer
|
||||
|
||||
Use `OpenCode.layer()` when the embedded host should be an application service.
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.Service
|
||||
return yield* opencode.health.get()
|
||||
})
|
||||
|
||||
const health = await Effect.runPromise(program.pipe(Effect.provide(OpenCode.layer())))
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: "Overview"
|
||||
---
|
||||
|
||||
`@opencode-ai/sdk` hosts OpenCode directly inside your application. Unlike the
|
||||
[network client](/build/client), it assembles the OpenCode server and routes API
|
||||
calls through its HTTP router in memory. It opens no HTTP listener and adds no
|
||||
network hop between the client and server.
|
||||
|
||||
<Callout type="warning">
|
||||
The V2 SDK is beta. Install the current preview with `bun add @opencode-ai/sdk@dev`; its API may change before a
|
||||
stable release.
|
||||
</Callout>
|
||||
|
||||
## Create a host
|
||||
|
||||
`OpenCode.create()` returns an explicitly owned host. Use `await using` to
|
||||
release its router, Location services, fibers, and scoped plugin registrations:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk"
|
||||
|
||||
await using opencode = await OpenCode.create()
|
||||
const session = await opencode.sessions.create({
|
||||
location: { directory: "/workspace" },
|
||||
})
|
||||
|
||||
await opencode.sessions.prompt({
|
||||
sessionID: session.id,
|
||||
text: "Review the current changes",
|
||||
})
|
||||
```
|
||||
|
||||
Call `await opencode.close()` explicitly when explicit resource management is
|
||||
not available.
|
||||
|
||||
The embedded host uses the same Promise values, declared errors, request
|
||||
options, and `AsyncIterable` streams as `@opencode-ai/client`. It exposes the
|
||||
full generated client and adds the convenience aliases `sessions` and `events`
|
||||
for the session and event groups.
|
||||
|
||||
## Stream events
|
||||
|
||||
```ts
|
||||
for await (const event of opencode.events.subscribe()) {
|
||||
console.log(event.type)
|
||||
}
|
||||
```
|
||||
|
||||
Pass an `AbortSignal` through the generated request options, or leave an
|
||||
iteration to cancel its response body.
|
||||
|
||||
## Register plugins
|
||||
|
||||
Pass initial Promise plugins to `OpenCode.create()`. Embedded plugins use the
|
||||
same discovery and Location-scoped activation path as configured plugins.
|
||||
|
||||
```ts
|
||||
const plugin = {
|
||||
id: "example",
|
||||
async setup(ctx) {
|
||||
await ctx.agent.transform((agents) => {
|
||||
// Modify the Location's agent catalog.
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
await using opencode = await OpenCode.create({ plugins: [plugin] })
|
||||
```
|
||||
|
||||
Call `await opencode.plugin(plugin)` to register another plugin after startup.
|
||||
|
||||
See the [Plugins guide](/build/plugins) for the plugin context and available
|
||||
hooks.
|
||||
@@ -2,11 +2,25 @@
|
||||
title: "Plugins"
|
||||
---
|
||||
|
||||
Add plugins to `cli.json`:
|
||||
Plugins configured in `opencode.json(c)` that expose a TUI component are loaded automatically by the CLI. To learn how
|
||||
to build plugins, see [Building plugins](/build/plugins). You do not need to add the same package to `cli.json`. The CLI
|
||||
gets the active plugin list from the connected OpenCode server, so this also works when the server is remote.
|
||||
|
||||
Use `cli.json` for CLI-only plugins. These plugins run locally in the terminal and remain active when the CLI connects
|
||||
to a remote server:
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"plugins": ["opencode.example", "./plugins/status.ts"]
|
||||
"plugins": [
|
||||
"opencode.example",
|
||||
"opencode.example@1.0.0",
|
||||
"@example/opencode-tui",
|
||||
"@example/opencode-tui@1.0.0",
|
||||
"./plugins/status.ts",
|
||||
"../plugins/status.ts",
|
||||
"/home/user/plugins/status.ts",
|
||||
"file:///home/user/plugins/status.ts"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -33,7 +47,10 @@ Pass plugin options with the object form:
|
||||
}
|
||||
```
|
||||
|
||||
`package` accepts a package name, an absolute path, a `file://` URL, or a path relative to `cli.json`.
|
||||
|
||||
OpenCode also discovers JavaScript and TypeScript plugins from `plugins/tui` under the global config directory and project
|
||||
`.opencode` directories.
|
||||
|
||||
```text title="Plugin discovery paths"
|
||||
<global-config>/plugins/tui/status.ts
|
||||
<project>/.opencode/plugins/tui/status.ts
|
||||
```
|
||||
|
||||
@@ -434,7 +434,7 @@ accepts options.
|
||||
}
|
||||
```
|
||||
|
||||
See the [plugins guide](/build/plugins) for plugin development and configuration.
|
||||
See the [plugins guide](/plugins) for plugin loading and configuration.
|
||||
|
||||
### Providers
|
||||
|
||||
|
||||
@@ -44,5 +44,6 @@ plan that grants you access to the best open source models.
|
||||
|
||||
## Customize
|
||||
|
||||
Make OpenCode your own by editing the [OpenCode config](/config), [connecting MCP servers](/mcp-servers), or [creating
|
||||
commands](/commands). For terminal interface themes and keybindings, see [CLI configuration](/cli/config).
|
||||
Make OpenCode your own by editing the [OpenCode config](/config), [loading plugins](/plugins), [connecting MCP
|
||||
servers](/mcp-servers), or [creating commands](/commands). For terminal interface themes and keybindings, see [CLI
|
||||
configuration](/cli/config).
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: "Plugins"
|
||||
---
|
||||
|
||||
Load published packages, versioned packages, scoped packages, local files, or configured plugins from `opencode.json(c)`.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugins": [
|
||||
"opencode-acme-plugin",
|
||||
"opencode-acme-plugin@1.2.0",
|
||||
"@acme/opencode-plugin",
|
||||
"./plugins/local.ts",
|
||||
"../shared/plugin.ts",
|
||||
"/absolute/path/plugin.ts",
|
||||
"file:///home/me/plugins/local.ts",
|
||||
{
|
||||
"package": "@acme/opencode-plugin",
|
||||
"options": {
|
||||
"agent": "reviewer",
|
||||
"strict": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Relative paths resolve from the config file containing the entry. Plugin arrays from applicable config files are applied
|
||||
from lowest to highest precedence instead of replacing one another.
|
||||
|
||||
```text
|
||||
~/.config/opencode/opencode.jsonc
|
||||
./opencode.jsonc
|
||||
./.opencode/opencode.jsonc
|
||||
```
|
||||
|
||||
OpenCode also loads direct `.ts` and `.js` files and immediate plugin package directories from every discovered
|
||||
`.opencode/plugins/` directory.
|
||||
|
||||
```text
|
||||
.opencode/
|
||||
└── plugins/
|
||||
├── concise.ts
|
||||
├── reviewer.js
|
||||
└── acme-package/
|
||||
```
|
||||
|
||||
Global plugins use the same discovery layout under the OpenCode config directory.
|
||||
|
||||
```text
|
||||
~/.config/opencode/plugins/
|
||||
```
|
||||
|
||||
A `plugins/` directory beside a project-root `opencode.json(c)` is not discovered automatically; configure its files
|
||||
explicitly or move it under `.opencode/`.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"plugins": ["./plugins/local.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
Plugin entries are processed in order. Prefix an ID or wildcard with `-` to disable it, use `*` for every plugin, and
|
||||
use `.*` to match an ID prefix. A later ID re-enables a plugin.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"plugins": ["*", "-opencode.provider.*", "opencode.provider.openai", "-acme.reviewer"]
|
||||
}
|
||||
```
|
||||
|
||||
Install, inspect, list, or remove global package plugins with the CLI.
|
||||
|
||||
```sh
|
||||
opencode2 plugin add opencode-acme-plugin@1.2.0
|
||||
opencode2 plugin list
|
||||
opencode2 plugin list --builtin
|
||||
opencode2 plugin remove opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
Package installation accepts npm names with versions, tags, or ranges. Configure local paths directly instead of using
|
||||
Git, tarball, or npm alias targets with `plugin add`.
|
||||
|
||||
```sh
|
||||
opencode2 plugin add @acme/opencode-plugin@beta
|
||||
```
|
||||
|
||||
Changes under watched config directories reload automatically. Restart OpenCode after changing an installed package
|
||||
version or an unwatched dependency.
|
||||
|
||||
```sh
|
||||
touch .opencode/plugins/concise.ts
|
||||
opencode2 service restart
|
||||
```
|
||||
|
||||
CLI-only plugins are configured separately and remain active when connected to a remote server.
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"plugins": ["opencode-acme-cli"]
|
||||
}
|
||||
```
|
||||
|
||||
<Card title="Build a plugin" href="/build/plugins">
|
||||
Create plugins that add tools, hooks, integrations, commands, agents, and other behavior.
|
||||
</Card>
|
||||
@@ -36,6 +36,7 @@ export const docsSections: DocsSection[] = [
|
||||
{ title: "Skills", slug: "skills" },
|
||||
{ title: "Themes", slug: "themes" },
|
||||
{ title: "Commands", slug: "commands" },
|
||||
{ title: "Plugins", slug: "plugins" },
|
||||
{ title: "Providers", slug: "providers" },
|
||||
{ title: "Snapshots", slug: "snapshots" },
|
||||
{ title: "Compaction", slug: "compaction" },
|
||||
@@ -63,7 +64,6 @@ export const docsSections: DocsSection[] = [
|
||||
landingSlug: "cli",
|
||||
groups: [
|
||||
{
|
||||
title: "Intro",
|
||||
items: [
|
||||
{ title: "Intro", slug: "cli" },
|
||||
{ title: "Config", slug: "cli/config" },
|
||||
@@ -88,11 +88,29 @@ export const docsSections: DocsSection[] = [
|
||||
landingSlug: "build",
|
||||
groups: [
|
||||
{
|
||||
items: [{ title: "Intro", slug: "build" }],
|
||||
},
|
||||
{
|
||||
title: "Plugins",
|
||||
items: [
|
||||
{ title: "Build", slug: "build" },
|
||||
{ title: "Plugins", slug: "build/plugins" },
|
||||
{ title: "Client", slug: "build/client" },
|
||||
{ title: "SDK", slug: "build/sdk" },
|
||||
{ title: "Overview", slug: "build/plugins" },
|
||||
{ title: "Effect", slug: "build/plugins/effect" },
|
||||
{ title: "CLI", slug: "build/plugins/cli" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Client",
|
||||
items: [
|
||||
{ title: "JavaScript", slug: "build/client" },
|
||||
{ title: "Effect", slug: "build/client/effect" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "SDK",
|
||||
items: [
|
||||
{ title: "Overview", slug: "build/sdk" },
|
||||
{ title: "Effect", slug: "build/sdk/effect" },
|
||||
{ title: "Cloudflare", slug: "build/sdk/cloudflare" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user