Compare commits

..
48 changed files with 1360 additions and 359 deletions
+1 -3
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpMiddleware, HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -155,7 +155,6 @@ export interface Interface {
}
export interface StreamOptions {
readonly transform?: HttpRequestTransform
readonly http?: HttpMiddleware
}
@@ -308,7 +307,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
transform: options?.transform,
middleware: options?.http,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
+1 -1
View File
@@ -270,7 +270,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
return transportError({ message: error.message, kind: "Timeout" })
}
if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: "HTTP transport failed" })
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
+1 -8
View File
@@ -23,11 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type {
HttpHandler,
HttpMiddleware,
HttpRequest,
HttpRequestTransform,
Transport as TransportDef,
TransportRuntime,
} from "./transport"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
+3 -5
View File
@@ -75,12 +75,10 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const transformed = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* prepareInput.transform?.(transformed) ?? Effect.void
const request = ProviderShared.jsonPost({
url: transformed.url,
body: transformed.body ?? "",
headers: Headers.fromInput(transformed.headers),
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
})
return {
request,
-10
View File
@@ -10,15 +10,6 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
@@ -32,7 +23,6 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform
readonly middleware?: HttpMiddleware
}
+12
View File
@@ -67,6 +67,18 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => {
it.effect("preserves middleware error messages", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, () => Effect.fail(new Error("plugin rejected request")))
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason.message).toBe("plugin rejected request")
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("classifies context overflow responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
+11 -4
View File
@@ -1502,18 +1502,25 @@ export interface ProjectCopyApi<E = never> {
export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly context?: number | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly status: VcsStatusOperation<E>
readonly diff: VcsDiffOperation<E>
}
+14 -3
View File
@@ -210,6 +210,8 @@ import type {
Endpoint25_0Output,
Endpoint25_1Input,
Endpoint25_1Output,
Endpoint25_2Input,
Endpoint25_2Output,
Endpoint26_0Output,
Endpoint26_1Input,
Endpoint26_1Output,
@@ -1182,17 +1184,26 @@ const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
preserveEffect<Endpoint25_0Output>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
preserveEffect<Endpoint25_2Output>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint25_0(raw),
status: Endpoint25_1(raw),
diff: Endpoint25_2(raw),
})
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
@@ -202,6 +202,8 @@ import type {
ProjectCopyRemoveOutput,
ProjectCopyRefreshInput,
ProjectCopyRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsDiffInput,
@@ -1702,6 +1704,18 @@ export function make(options: ClientOptions) {
),
},
vcs: {
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
{
method: "GET",
path: `/api/vcs`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
request<VcsStatusOutput>(
{
@@ -530,6 +530,8 @@ export type ReferenceGitSource = {
export type ProjectCopyCopy = { directory: string }
export type VcsBranch = { current?: string; default?: string }
export type VcsFileStatus = {
file: string
additions: number
@@ -1745,6 +1747,8 @@ export type SessionStatus2 = {
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
export type SessionInfo = {
@@ -4902,6 +4906,17 @@ export type ProjectCopyRefreshInput = {
export type ProjectCopyRefreshOutput = void
export type VcsGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: VcsInfo
}
export type VcsStatusInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+1 -1
View File
@@ -44,7 +44,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
+20 -2
View File
@@ -1,11 +1,16 @@
export * as Agent from "./agent"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "./bus"
import { State } from "./state"
const SHELL_OUTPUT_GLOB = (data: string) => path.join(data, "shell", "*", "*")
const TOOL_OUTPUT_GLOB = (data: string) => path.join(data, "tool-output", "*")
export const ID = Agent.ID
export type ID = typeof ID.Type
export const Name = Agent.Name
@@ -51,6 +56,13 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const global = yield* Global.Service
const permissions: Info["permissions"] = [
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB(global.data), effect: "allow" },
{ action: "external_directory", resource: TOOL_OUTPUT_GLOB(global.data), effect: "allow" },
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
const state = State.create<Data, Draft>({
name: "agent",
initial: () => ({ agents: new Map() }),
@@ -61,7 +73,13 @@ const layer = Layer.effect(
draft.default = id
},
update: (id, fn) => {
const current = draft.agents.get(id) ?? (Info.default(id) as Types.DeepMutable<Info>)
const defaults = Info.default(id)
const current =
draft.agents.get(id) ??
({
...defaults,
permissions: [...defaults.permissions, ...permissions],
} as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
@@ -114,4 +132,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Global.node] })
+1 -1
View File
@@ -413,7 +413,7 @@ function mapBodyToProviderOptions(model: Info, packageName: string) {
function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
return {
prompt: prompt(request),
maxOutputTokens: request.generation?.maxTokens ?? request.model.route.defaults.limits?.output,
maxOutputTokens: request.generation?.maxTokens,
temperature: request.generation?.temperature,
stopSequences: request.generation?.stop === undefined ? undefined : [...request.generation.stop],
topP: request.generation?.topP,
@@ -71,6 +71,14 @@ const layer = Layer.effect(
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
if (!config.includes(".hg") && !config.includes(vcs)) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
+4 -1
View File
@@ -2,6 +2,7 @@ export * as InstructionBuiltIns from "./builtins"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location"
import { SessionSchema } from "../session/schema"
import { Instructions } from "./index"
@@ -15,6 +16,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const location = yield* Location.Service
return Service.of({
load: (sessionID) =>
@@ -31,6 +33,7 @@ const layer = Layer.effect(
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>",
].join("\n"),
),
@@ -58,4 +61,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Global.node, Location.node] })
+17 -52
View File
@@ -1,17 +1,10 @@
export * as AgentPlugin from "./agent"
import path from "path"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Agent } from "../agent"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location"
import { Permission } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your strengths:
@@ -100,38 +93,12 @@ Rules:
export const Plugin = define({
id: "opencode.agent",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const worktree = location.directory
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: Permission.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(
(resource): Permission.Rule => ({ action: "external_directory", resource, effect: "allow" }),
),
]
const defaults: Permission.Ruleset = [
{ action: "*", resource: "*", effect: "allow" },
...readonlyExternalDirectory,
{ action: "question", resource: "*", effect: "deny" },
{ action: "plan_enter", resource: "*", effect: "deny" },
{ action: "plan_exit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
]
yield* ctx.agent.transform((draft) => {
draft.update(Agent.defaultID, (item) => {
item.name = Agent.Name.make("Build")
item.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary"
item.permissions.push(
...Permission.merge(defaults, [
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_enter", resource: "*", effect: "allow" },
]),
)
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
draft.update(Agent.ID.make("plan"), (item) => {
@@ -139,18 +106,8 @@ export const Plugin = define({
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
...Permission.merge(defaults, [
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_exit", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
{
action: "edit",
resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
effect: "allow",
},
]),
{ action: "question", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
)
})
@@ -159,10 +116,16 @@ export const Plugin = define({
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
item.permissions.push(...Permission.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }]))
item.permissions.push(
{ action: "question", resource: "*", effect: "deny" },
{ action: "subagent", resource: "*", effect: "deny" },
)
})
draft.update(Agent.ID.make("explore"), (item) => {
const externalDirectories = item.permissions.filter(
(rule) => rule.action === "external_directory" && rule.effect === "allow",
)
item.name = Agent.Name.make("Explore")
item.description =
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
@@ -170,7 +133,6 @@ export const Plugin = define({
item.mode = "subagent"
item.permissions.push(
...Permission.merge(
defaults,
[
{ action: "*", resource: "*", effect: "deny" },
{ action: "grep", resource: "*", effect: "allow" },
@@ -178,9 +140,12 @@ export const Plugin = define({
{ action: "webfetch", resource: "*", effect: "allow" },
{ action: "websearch", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
{ action: "subagent", resource: "*", effect: "deny" },
],
readonlyExternalDirectory,
[{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories],
),
)
})
@@ -190,7 +155,7 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
draft.update(Agent.ID.make("title"), (item) => {
@@ -198,7 +163,7 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_TITLE
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
draft.update(Agent.ID.make("summary"), (item) => {
@@ -206,7 +171,7 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
})
}),
+61 -3
View File
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
}),
)
function sessionHook<Name extends keyof SessionHooks>(
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
): Promise<Registration>
function sessionHook(
...registration: {
[Name in keyof SessionHooks]: [
name: Name,
callback: (event: SessionHooks[Name]) => Promise<void> | void,
]
}[keyof SessionHooks]
) {
if (registration[0] !== "http")
return register(
host.session.hook(registration[0], (event) =>
Effect.promise(() => Promise.resolve(registration[1](event))),
),
)
return register(
host.session.hook("http", (event) => {
const middlewares: SessionHttpMiddleware[] = []
const output: SessionHttp = {
...event,
use: (item) => {
middlewares.push(item)
},
}
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
Effect.flatMap(() =>
Effect.forEach(
middlewares,
(item) =>
event.use((input, next) =>
Effect.tryPromise({
try: (signal) => {
const inputSignal = AbortSignal.any([signal, input.signal])
return Promise.resolve(
item(new Request(input, { signal: inputSignal }), (request) => {
const requestSignal = AbortSignal.any([signal, request.signal])
return Effect.runPromiseWith(
context,
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
}),
)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}),
),
{ discard: true },
),
),
)
}),
)
}
const context2: Context = {
app: host.app,
options: host.options,
@@ -194,7 +251,9 @@ export function fromPromise(plugin: Plugin) {
),
),
refresh:
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
@@ -263,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
hook: sessionHook,
create: (input) =>
run(
host.session.create(
+8 -9
View File
@@ -225,15 +225,14 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook("request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
yield* ctx.session.hook("http", (evt) =>
evt.use((request, next) => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
const url = new URL(request.url)
request.headers.set("originator", "opencode")
request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return next(request)
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
}),
)
+72 -39
View File
@@ -2,9 +2,11 @@ export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Model } from "../model"
@@ -48,9 +50,7 @@ interface Prepared {
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
*/
readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.Result, ExecuteError>
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean
}
@@ -137,8 +137,7 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
result: {
...part.result,
value: part.result.value.map((item: Content) => {
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
return item
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return item
removed += Buffer.byteLength(item.uri)
return { type: "text" as const, text: IMAGE_REMOVED }
}),
@@ -189,55 +188,87 @@ export const layer = Layer.effect(
.map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
const toolDefinitions = tools.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
const contextEvent = yield* hooks.trigger("session", "context", {
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
// tool by moving its definition to a new key; recognizing the object recovers the tool.
const given = new Map(
tools.definitions.map(
(tool) => [{ description: tool.description, input: { ...tool.inputSchema } }, tool] as const,
),
)
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
const context = yield* hooks.trigger("session", "context", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system,
messages,
tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = toolsByName.get(name)
return registered
? [{ ...registered, description: tool.description, inputSchema: tool.input }]
: []
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
})
// Match each surviving entry back to its tool, by recognizing a moved definition or
// by key. Identity wins so a definition moved onto another tool's name still executes
// the tool it describes. Entries matching neither were invented by a hook and dropped.
// `tool.name` stays canonical so execution can translate renamed calls back.
const hooked = new Map(
Object.entries(context.tools).flatMap(([name, definition]) => {
const tool = given.get(definition) ?? registry.get(name)
if (!tool) return []
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
}),
)
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session, app),
},
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
system: contextEvent.system,
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
tools: hookedTools,
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
transform: (request) =>
hooks
.trigger("session", "request", {
http: (request, handler) =>
Effect.gen(function* () {
let latest = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
const middlewares: SessionHttpMiddleware[] = []
const web = yield* HttpClientRequest.toWeb(request)
yield* hooks.trigger("session", "http", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
...request,
})
.pipe(
Effect.tap((event) =>
use: (item) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
middlewares.push(item)
}),
),
Effect.asVoid,
),
})
const send = (input: Request) =>
Effect.gen(function* () {
let sent = HttpClientRequest.fromWeb(input)
if (input.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
input.headers.get("content-type") ?? undefined,
)
latest = sent
const response = yield* handler(sent)
const body = [204, 205, 304].includes(response.status)
? null
: yield* Stream.toReadableStreamEffect(response.stream)
const output = new Response(body, { status: response.status, headers: response.headers })
origins.set(output, sent)
return output
})
const dispatch = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
send,
)
const response = yield* dispatch(web)
const origin = origins.get(response) ?? latest
return HttpClientResponse.fromWeb(origin, response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
@@ -256,13 +287,15 @@ export const layer = Layer.effect(
}),
)
}
const executeTool: Prepared["executeTool"] = (executeInput) => {
const executeTool: Prepared["executeTool"] = (input) => {
if (stepLimitReached)
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` })
const tool = hooked.get(input.call.name)
// A registered tool absent from the hooked set was removed or renamed by a hook.
if (!tool && registry.has(input.call.name))
return new Tool.Error({ message: `Tool is not available for this request: ${input.call.name}` })
return tools
.execute(executeInput)
.execute(tool ? { ...input, call: { ...input.call, name: tool.name } } : input)
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
}
return {
+37 -4
View File
@@ -1,22 +1,27 @@
export * as Vcs from "./vcs"
import { Context, Effect, Layer } from "effect"
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "./bus"
import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg"
export { FileStatus, Mode }
export { FileStatus, Info, Mode }
export interface DiffOptions {
readonly context?: number
}
export interface Interface {
readonly info: () => Effect.Effect<Info>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
}
@@ -38,8 +43,36 @@ const layer = Layer.effect(
const proc = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const impl = adapter(proc, fs, location)
const vcs = location.vcs
const state = { info: impl ? yield* impl.info() : { branch: {} } satisfies Info }
if (vcs && impl) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
const isBranchMetadata =
vcs.type === "git"
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
: (file: string) => path.resolve(file) === path.join(store, "branch")
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}
return Service.of({
info: Effect.fn("Vcs.info")(function* () {
return state.info
}),
status: Effect.fn("Vcs.status")(function* () {
if (!impl) return []
return yield* impl.status()
@@ -55,5 +88,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node],
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
})
+7 -1
View File
@@ -3,7 +3,7 @@ export * as VcsGit from "./git"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch"
@@ -20,6 +20,12 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
return {
info: Effect.fn("VcsGit.info")(function* () {
const [current, root] = yield* Effect.all([ctx.git.branch(ctx.directory), ctx.git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
return { branch: { current, default: root?.name } } satisfies Info
}),
status: Effect.fn("VcsGit.status")(function* () {
const git = ctx.git
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
+4 -1
View File
@@ -4,7 +4,7 @@ import path from "path"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs"
@@ -73,6 +73,9 @@ export function make(
})
return {
info: Effect.fn("VcsHg.info")(function* () {
return { branch: { current: yield* hg.branch(), default: "default" } } satisfies Info
}),
status: Effect.fn("VcsHg.status")(function* () {
const [items, batch] = yield* Effect.all(
// Zero-context patches are enough to count changed lines.
+36 -12
View File
@@ -1,3 +1,4 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
@@ -9,15 +10,19 @@ import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host"
const testLocation = location({ directory: AbsolutePath.make("/project") })
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
const global = Global.make({ data: "/data", config: "/config", tmp: "/tmp/opencode" })
const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
[Global.node, globalLayer],
[Location.node, locationLayer],
]) as unknown as Layer.Layer<unknown, never>,
)
@@ -120,25 +125,35 @@ describe("Agent", () => {
const id = Agent.ID.make("custom")
yield* agent.transform((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toEqual(Agent.Info.default(id))
const info = yield* agent.get(id)
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
Agent.Info.default(id).permissions,
)
expect(Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect).toBe(
"allow",
)
yield* agent.transform((editor) => editor.remove(id))
expect(yield* agent.get(id)).toBeUndefined()
}),
)
it.effect("does not ambiently opt built-in agents into bash", () =>
it.effect("applies managed external directories without opting built-in agents into bash", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* AgentPlugin.Plugin.effect(
host({
agent: agentHost(agent),
}),
).pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
),
)
const agents = yield* agent.list()
@@ -152,6 +167,20 @@ describe("Agent", () => {
"title",
])
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
expect(
Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), permissions).effect,
).toBe("allow")
expect(
Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), permissions).effect,
).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
const explore = yield* agent.get(Agent.ID.make("explore"))
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
expect(Permission.evaluate("read", "src/index.ts", explore?.permissions ?? []).effect).toBe("allow")
for (const item of agents) {
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
}
@@ -165,11 +194,6 @@ describe("Agent", () => {
host({
agent: agentHost(agent),
}),
).pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
),
)
yield* Effect.forEach(["general", "explore"], (id) =>
+17
View File
@@ -98,6 +98,23 @@ it.effect("projects request settings, headers, and body overlays", () =>
}),
)
it.effect("leaves max output tokens unset when the request omits them", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model({
...model("@openrouter/ai-sdk-provider"),
limit: { context: 500_000, output: 500_000 },
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.maxOutputTokens).toBeUndefined()
}),
)
it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+14 -9
View File
@@ -20,10 +20,13 @@ import { agentHost, host } from "../plugin/host"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
const decode = Schema.decodeUnknownSync(Config.Info)
const defaultPermissions = [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
] satisfies Permission.Ruleset
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
...Agent.Info.default(Agent.ID.make("test")).permissions,
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
test("rejects named agent color tokens", () => {
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
@@ -58,6 +61,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const global = yield* Global.Service
const build = Agent.ID.make("build")
yield* agents.transform((editor) =>
editor.update(build, (agent) => {
@@ -110,7 +114,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
...defaultPermissions,
...defaultPermissions(global),
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
@@ -128,7 +132,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
})
expect(reviewer.permissions).toEqual([
...defaultPermissions,
...defaultPermissions(global),
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
@@ -136,7 +140,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
])
expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
...defaultPermissions,
...defaultPermissions(global),
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" },
@@ -270,6 +274,7 @@ Use native v2 fields.`,
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
})
const agents = yield* Agent.Service
const global = yield* Global.Service
const entries = [
new Config.Document({
type: "document",
@@ -287,13 +292,13 @@ Use native v2 fields.`,
system: "Review carefully.",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
+26 -7
View File
@@ -153,12 +153,16 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: { git?: boolean; init?: (directory: string) => Promise<void> },
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
) {
return Effect.acquireRelease(
Effect.promise(async () => {
const tmp = await tmpdir()
if (!options?.git) return { tmp, vcs: undefined }
if (options?.vcs === "hg") {
await fs.mkdir(path.join(tmp.path, ".hg"))
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
}
if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
@@ -292,7 +296,7 @@ describeWatcher("LocationWatcher", () => {
})
}
}),
{ git: true },
{ vcs: "git" },
),
)
@@ -322,7 +326,7 @@ describeWatcher("LocationWatcher", () => {
}),
)
}),
{ git: true },
{ vcs: "git" },
),
)
@@ -359,7 +363,7 @@ describeWatcher("LocationWatcher", () => {
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
)
}),
{ git: true },
{ vcs: "git" },
),
)
@@ -376,7 +380,7 @@ describeWatcher("LocationWatcher", () => {
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toMatchObject({ file: head })
}),
{ git: true },
{ vcs: "git" },
),
)
@@ -401,7 +405,7 @@ describeWatcher("LocationWatcher", () => {
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
}),
{
git: true,
vcs: "git",
init: async (directory) => {
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
await fs.rename(path.join(directory, ".git"), actual)
@@ -411,4 +415,19 @@ describeWatcher("LocationWatcher", () => {
),
)
})
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(directory)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg" },
),
)
})
+2 -2
View File
@@ -5,8 +5,8 @@ import path from "path"
import { Global } from "@opencode-ai/util/global"
describe("global paths", () => {
test("tmp path is under the system temp directory", () => {
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
test("tmp path is the canonical system temp directory", async () => {
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
@@ -29,7 +29,7 @@ const locationLayer = Layer.succeed(
const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
]),
)
@@ -49,6 +49,7 @@ describe("InstructionBuiltIns", () => {
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
+114 -15
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
import { host as testHost } from "./host"
@@ -148,7 +148,9 @@ describe("fromPromise", () => {
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
description: "Reviews code",
})
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
"Agent not found: missing",
)
const models = (await ctx.catalog.model.list()).data
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
modelID: "gpt-5",
@@ -221,6 +223,105 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
const bodies: string[] = []
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
request.headers.set("x-hook", "promise")
await next(request)
const response = await next(request)
return new Response(`${await response.text()}-response`)
})
})
await ctx.session.hook("http", (event) => {
event.use(async (request, next) => {
const response = await next(request)
return new Response(`${await response.text()}-outer`)
})
})
},
}),
).effect(host)
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) =>
Effect.promise(() => input.text()).pipe(
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
),
)
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
expect(bodies).toEqual(["payload", "payload"])
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
}),
)
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http-interrupt",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
event.use((request, next) => next(request))
})
},
}),
).effect(host)
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
const event: PluginHooks.Domains["session"]["http"] = {
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
}
yield* hooks.trigger("session", "http", event)
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
() =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
)
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
}),
)
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -315,19 +416,17 @@ describe("fromPromise", () => {
id: "promise-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add(
{
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
tools.add({
name: "hello",
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }, context) => {
await context.progress({ phase: "greeting" })
return { output: `Hello, ${name}!` }
},
)
})
})
},
})
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Provider } from "@opencode-ai/core/provider"
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -29,6 +30,29 @@ function required<T>(value: T | undefined): T {
return value
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
use: (item) =>
Effect.sync(() => {
middlewares.push(item)
}),
})
const request = middlewares.reduce<SessionHttpHandler>(
(next, item) => (input: Request) => item(input, next),
(input: Request) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
)
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
})
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -100,33 +124,9 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
@@ -134,7 +134,7 @@ describe("OpenAIPlugin", () => {
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).toEqual({})
expect(custom.headers).not.toHaveProperty("originator")
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
@@ -184,21 +184,13 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).toEqual({})
expect(request.headers).not.toHaveProperty("originator")
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
@@ -41,6 +41,7 @@ import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import path from "node:path"
import { testEffect } from "./lib/effect"
import { agentHost, catalogHost, host } from "./plugin/host"
@@ -104,37 +105,39 @@ const promptCatalog = Layer.mock(Catalog.Service, {
small: () => Effect.succeed(undefined),
},
})
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[McpInstructions.node, mcpInstructions],
[Config.node, config],
[Permission.node, permission],
[PluginSupervisor.node, pluginSupervisor],
])
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer))
const it = testEffect(
const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, llmClient],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[McpInstructions.node, mcpInstructions],
[Config.node, config],
[Permission.node, permission],
[PluginSupervisor.node, pluginSupervisor],
])
const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer(llmClient)))
const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
AppNodeBuilder.build(
LayerNode.group([
Database.node,
@@ -156,7 +159,7 @@ const it = testEffect(
Session.node,
]),
[
[LayerNodePlatform.llmClient, client],
[LayerNodePlatform.llmClient, llmClient],
[Permission.node, permission],
[Catalog.node, promptCatalog],
[SessionRunnerModel.node, models],
@@ -168,10 +171,10 @@ const it = testEffect(
[Config.node, config],
[Snapshot.node, Snapshot.noopLayer],
[PluginSupervisor.node, pluginSupervisor],
[SessionExecution.node, execution],
[SessionExecution.node, execution(llmClient)],
],
),
)
)
const it = testEffect(testLayer(client))
const sessionID = Session.ID.make("ses_runner_recorded")
describe("SessionRunnerLLM recorded", () => {
@@ -247,3 +250,90 @@ describe("SessionRunnerLLM recorded", () => {
}),
)
})
describe("SessionModelRequest HTTP bridge", () => {
const bodies: Uint8Array[] = []
const methods: string[] = []
const response = [
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
"data: [DONE]",
"",
].join("\n\n")
const transport = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
methods.push(request.method)
bodies.push(request.body.body.slice())
return HttpClientResponse.fromWeb(
request,
new Response(response, { headers: { "content-type": "text/event-stream" } }),
)
}),
),
)
const retryIt = testEffect(
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
)
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
Effect.gen(function* () {
bodies.length = 0
methods.length = 0
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
}),
)
const pluginHost = host({
agent: agentHost(agents),
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* pluginHost.session.hook("http", (event) =>
event.use((request, next) =>
Effect.gen(function* () {
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
return yield* next(request)
}),
),
)
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const retrySessionID = Session.ID.make("ses_model_request_http_retry")
yield* db
.insert(SessionTable)
.values({
id: retrySessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const session = yield* Session.Service
yield* session.prompt({ sessionID: retrySessionID, text: "Say hello.", resume: false })
yield* session.resume(retrySessionID)
expect(methods).toEqual(["POST", "POST"])
expect(bodies).toHaveLength(2)
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
expect(bodies[1]).toEqual(bodies[0])
}),
)
})
+21
View File
@@ -887,6 +887,27 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("executes a tool renamed by a session context hook", () =>
Effect.gen(function* () {
const session = yield* setup
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.tools.renamed_echo = event.tools.echo!
delete event.tools.echo
}),
)
yield* admit(session, "Use the renamed tool")
yield* TestLLM.push(TestLLM.tool("call-renamed", "renamed_echo", { text: "renamed" }), [])
yield* session.resume(sessionID)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("renamed_echo")
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(executions).toEqual(["renamed"])
}),
)
it.effect("advertises and executes a location registered tool", () =>
Effect.gen(function* () {
const session = yield* setup
+52 -17
View File
@@ -2,11 +2,14 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Effect, Fiber, Layer, Stream } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
@@ -15,7 +18,7 @@ const describeHg = Bun.which("hg") ? describe : describe.skip
const provide = (directory: string) =>
Effect.provide(
LayerNode.compile(Vcs.node, [
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[
Location.node,
Layer.succeed(
@@ -37,6 +40,11 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
const withHg = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
withTmp((directory) =>
Effect.promise(() => hg(directory, "init")).pipe(Effect.andThen(f(directory).pipe(provide(directory)))),
)
async function hg(directory: string, ...args: string[]) {
await $`hg ${args}`.cwd(directory).env({ ...process.env, HGPLAIN: "1" }).quiet()
}
@@ -48,10 +56,9 @@ async function commitAll(directory: string, message: string) {
describeHg("Vcs mercurial", () => {
it.live("reports modified, missing, and untracked files", () =>
withTmp((directory) =>
withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
await commitAll(directory, "initial")
@@ -66,15 +73,14 @@ describeHg("Vcs mercurial", () => {
{ file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
{ file: "new.txt", additions: 2, deletions: 0, status: "added" },
])
}).pipe(provide(directory)),
}),
),
)
it.live("diffs the working copy with synthesized untracked and missing patches", () =>
withTmp((directory) =>
withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
await commitAll(directory, "initial")
@@ -96,16 +102,47 @@ describeHg("Vcs mercurial", () => {
expect(diff[1].deletions).toBe(1)
expect(diff[2].patch).toContain("+hello")
expect(diff[2].additions).toBe(1)
}).pipe(provide(directory)),
}),
),
)
it.live("caches branch info and publishes branch metadata changes", () =>
withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
Stream.take(1),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
yield* bus.publish(FileSystem.Event.Changed, {
file: path.join(directory, ".hg", "branch"),
event: "change",
})
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
}),
),
)
it.live("respects the context option", () =>
withTmp((directory) =>
withHg((directory) =>
Effect.gen(function* () {
const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "file.txt"), body)
await commitAll(directory, "initial")
await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed"))
@@ -117,15 +154,14 @@ describeHg("Vcs mercurial", () => {
const tight = yield* vcs.diff("working", { context: 1 })
expect(tight[0].patch).toContain("line-9")
expect(tight[0].patch).not.toContain("line-0")
}).pipe(provide(directory)),
}),
),
)
it.live("diffs before the first commit", () =>
withTmp((directory) =>
withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "tracked.txt"), "a\nb\n")
await hg(directory, "add", "-q", "tracked.txt")
await fs.writeFile(path.join(directory, "loose.txt"), "hello\n")
@@ -139,15 +175,14 @@ describeHg("Vcs mercurial", () => {
expect(diff).toHaveLength(2)
expect(diff[0].patch).toContain("+hello")
expect(diff[1].patch).toContain("+a")
}).pipe(provide(directory)),
}),
),
)
it.live("diffs a named branch against the default branch", () =>
withTmp((directory) =>
withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await hg(directory, "init")
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
@@ -164,7 +199,7 @@ describeHg("Vcs mercurial", () => {
{ file: "file.txt", status: "modified" },
])
expect(diff[0].patch).toContain("+two")
}).pipe(provide(directory)),
}),
),
)
})
+54 -17
View File
@@ -2,18 +2,21 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Effect, Fiber, Layer, Stream } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide(
LayerNode.compile(Vcs.node, [
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[
Location.node,
Layer.succeed(
@@ -35,6 +38,13 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
withTmp((directory) =>
Effect.promise(() => initRepo(directory)).pipe(
Effect.andThen(f(directory).pipe(provide(directory, { git: true }))),
),
)
async function initRepo(directory: string) {
await $`git init -b main`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
@@ -53,6 +63,7 @@ describe("Vcs", () => {
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
@@ -61,10 +72,9 @@ describe("Vcs", () => {
)
it.live("reports modified, deleted, and untracked files", () =>
withTmp((directory) =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
await commitAll(directory, "initial")
@@ -79,15 +89,45 @@ describe("Vcs", () => {
{ file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
{ file: "new.txt", additions: 2, deletions: 0, status: "added" },
])
}).pipe(provide(directory, { git: true })),
}),
),
)
it.live("caches branch info and publishes HEAD changes", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
Stream.take(1),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
}),
),
)
it.live("diffs the working copy against HEAD with patches", () =>
withTmp((directory) =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await commitAll(directory, "initial")
await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n")
@@ -105,16 +145,15 @@ describe("Vcs", () => {
expect(diff[0].deletions).toBe(1)
expect(diff[1].patch).toContain("+hello")
expect(diff[1].additions).toBe(1)
}).pipe(provide(directory, { git: true })),
}),
),
)
it.live("respects the context option", () =>
withTmp((directory) =>
withGit((directory) =>
Effect.gen(function* () {
const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), body)
await commitAll(directory, "initial")
await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed"))
@@ -126,15 +165,14 @@ describe("Vcs", () => {
const tight = yield* vcs.diff("working", { context: 1 })
expect(tight[0].patch).toContain("line-9")
expect(tight[0].patch).not.toContain("line-0")
}).pipe(provide(directory, { git: true })),
}),
),
)
it.live("diffs before the first commit", () =>
withTmp((directory) =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "new.txt"), "hello\n")
})
const vcs = yield* Vcs.Service
@@ -142,15 +180,14 @@ describe("Vcs", () => {
const diff = yield* vcs.diff("working")
expect(diff).toHaveLength(1)
expect(diff[0].patch).toContain("+hello")
}).pipe(provide(directory, { git: true })),
}),
),
)
it.live("diffs a feature branch against the default branch", () =>
withTmp((directory) =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
@@ -167,7 +204,7 @@ describe("Vcs", () => {
{ file: "file.txt", status: "modified" },
])
expect(diff[0].patch).toContain("+two")
}).pipe(provide(directory, { git: true })),
}),
),
)
})
+11 -4
View File
@@ -1,10 +1,9 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Effect, JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -16,15 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
}
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Effect.Effect<Response, Error>
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
readonly http: SessionHttp
}
export type SessionDomain = Pick<
+10 -3
View File
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -16,15 +15,23 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionRequest extends HttpRequest {
export interface SessionHttp {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly use: (middleware: SessionHttpMiddleware) => void
}
export type SessionHttpHandler = (request: Request) => Promise<Response>
export type SessionHttpMiddleware = (
request: Request,
next: SessionHttpHandler,
) => Promise<Response> | Response
export interface SessionHooks {
readonly context: SessionContext
readonly request: SessionRequest
readonly http: SessionHttp
}
export type SessionDomain = Pick<
+153 -6
View File
@@ -851,6 +851,16 @@
}
]
},
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"agent": {
"anyOf": [
{
@@ -11301,6 +11311,104 @@
}
}
},
"/api/vcs": {
"get": {
"tags": [
"vcs"
],
"operationId": "v2.vcs.get",
"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
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Info"
},
"data": {
"$ref": "#/components/schemas/Vcs.Info"
}
},
"required": [
"location",
"data"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Get current and default branch information for the requested location.",
"summary": "VCS info"
}
},
"/api/vcs/status": {
"get": {
"tags": [
@@ -12032,11 +12140,15 @@
},
"directory": {
"type": "string"
},
"canonical": {
"type": "string"
}
},
"required": [
"id",
"directory"
"directory",
"canonical"
],
"additionalProperties": false
}
@@ -12498,7 +12610,6 @@
"cost",
"tokens",
"time",
"title",
"location"
],
"additionalProperties": false
@@ -13837,6 +13948,15 @@
},
"message": {
"type": "string"
},
"status": {
"type": "integer",
"allOf": [
{
"minimum": 100,
"maximum": 599
}
]
}
},
"required": [
@@ -20765,7 +20885,7 @@
"id": {
"type": "string"
},
"worktree": {
"canonical": {
"type": "string"
},
"vcs": {
@@ -20792,7 +20912,7 @@
},
"required": [
"id",
"worktree",
"canonical",
"time",
"sandboxes"
],
@@ -20806,11 +20926,15 @@
},
"directory": {
"type": "string"
},
"canonical": {
"type": "string"
}
},
"required": [
"id",
"directory"
"directory",
"canonical"
],
"additionalProperties": false
},
@@ -22445,7 +22569,6 @@
"slug",
"projectID",
"directory",
"title",
"version",
"time"
],
@@ -29127,6 +29250,30 @@
],
"additionalProperties": false
},
"Vcs.Branch": {
"type": "object",
"properties": {
"current": {
"type": "string"
},
"default": {
"type": "string"
}
},
"additionalProperties": false
},
"Vcs.Info": {
"type": "object",
"properties": {
"branch": {
"$ref": "#/components/schemas/Vcs.Branch"
}
},
"required": [
"branch"
],
"additionalProperties": false
},
"Vcs.FileStatus": {
"type": "object",
"properties": {
+14
View File
@@ -13,6 +13,20 @@ const DiffQuery = Schema.Struct({
})
export const VcsGroup = HttpApiGroup.make("server.vcs")
.add(
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
query: LocationQuery,
success: Location.response(Vcs.Info),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.get",
summary: "VCS info",
description: "Get current and default branch information for the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("vcs.status", "/api/vcs/status", {
query: LocationQuery,
+3
View File
@@ -46,6 +46,9 @@ export const Info = Schema.Struct({
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
],
}) satisfies Info,
})),
+12 -1
View File
@@ -1,7 +1,18 @@
export * as Vcs from "./vcs.js"
import { Schema } from "effect"
import { NonNegativeInt } from "./schema.js"
import { NonNegativeInt, optional } from "./schema.js"
export const Branch = Schema.Struct({
current: optional(Schema.String),
default: optional(Schema.String),
}).annotate({ identifier: "Vcs.Branch" })
export interface Branch extends Schema.Schema.Type<typeof Branch> {}
export const Info = Schema.Struct({
branch: Branch,
}).annotate({ identifier: "Vcs.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" })
export type Mode = typeof Mode.Type
@@ -16,6 +16,7 @@ import { FileDiff } from "../src/file-diff.js"
import { Money } from "../src/money.js"
import { Skill } from "../src/skill.js"
import { Shell } from "../src/shell.js"
import { Vcs } from "../src/vcs.js"
import { PersistedRevert } from "../src/session-revert.js"
import { AbsolutePath, optional } from "../src/schema.js"
@@ -134,6 +135,10 @@ describe("contract hygiene", () => {
expect(Pty.ID.create()).toStartWith("pty_")
})
test("VCS info omits unavailable branch names", () => {
expect(Schema.encodeSync(Vcs.Info)({ branch: { current: undefined, default: undefined } })).toEqual({ branch: {} })
})
test("reusable public identifiers are stable and unique", () => {
const identifiers = [
Agent.Color,
@@ -166,6 +171,8 @@ describe("contract hygiene", () => {
SessionPending.SyntheticData,
SessionPending.User,
SessionPending.Synthetic,
Vcs.Branch,
Vcs.Info,
].map((schema) => schema.ast.annotations?.identifier)
expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true)
+8
View File
@@ -7,6 +7,14 @@ import { response } from "../location"
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("vcs.get", () =>
response(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.info()
}),
),
)
.handle("vcs.status", () =>
response(
Effect.gen(function* () {
-20
View File
@@ -125,7 +125,6 @@ type ToolName =
| "webfetch"
| "websearch"
| "skill"
| "plan_exit"
type ToolRule = {
view: ToolView
@@ -516,15 +515,6 @@ function runLsp(p: ToolProps): ToolInline {
}
}
function runPlanExit(p: ToolProps): ToolInline {
return {
icon: "→",
title: "Switching to build agent",
mode: "block",
body: p.frame.status === "completed" ? p.frame.output : undefined,
}
}
function patchTitle(file: PatchFile, directory?: string): string {
if (file.status === "added") {
return `# Created ${toolPath(file.file, { directory })}`
@@ -1077,16 +1067,6 @@ const TOOL_RULES = {
start: scrollSkillStart,
},
},
plan_exit: {
view: {
output: true,
final: false,
},
run: runPlanExit,
scroll: {
start: () => "",
},
},
} as const satisfies ToolRegistry
function key(name: string): name is ToolName {
+3 -2
View File
@@ -13,6 +13,8 @@ const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)
const tmp = path.join(os.tmpdir(), app)
await fs.mkdir(tmp, { recursive: true })
const paths = {
get home() {
return process.env.OPENCODE_TEST_HOME ?? os.homedir()
@@ -24,7 +26,7 @@ const paths = {
cache,
config,
state,
tmp,
tmp: await fs.realpath(tmp),
}
export const Path = paths
@@ -35,7 +37,6 @@ await Promise.all([
fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.tmp, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
fs.mkdir(Path.repos, { recursive: true }),
@@ -81,8 +81,7 @@ current built-in actions use these resources:
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
Built-in agent policy also reserves `plan_enter` and `plan_exit` for plan-mode
transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.
`doom_loop` and `lsp` are not current V2 Core permission actions.
## External directories
@@ -132,7 +131,9 @@ matching, so authorize only trusted directory boundaries.
## Defaults
The evaluator's fallback is `ask`, but shipped agents include ordered defaults:
Every agent, including custom agents, starts with ordered defaults that allow
tools, ask for external directories, ask for `.env` reads, and allow
`.env.example` reads. Shipped agents then add their own policies:
| Agent | Effective default policy |
| --- | --- |
@@ -153,8 +154,12 @@ The base read rules are ordered as follows:
]
```
OpenCode also permits its managed tool-output and temporary directories where
needed. These exceptions do not grant general external-directory access.
OpenCode also permits its managed tool-output, shell-output, temporary, and
global configuration directories. These exceptions apply only to the
external-directory boundary for every agent; the underlying action still uses
its own permission rules. The environment instructions identify the temporary
directory available for work outside the workspace. Later global and
agent-specific rules can override these defaults.
## Agent overrides
+18 -5
View File
@@ -239,16 +239,29 @@ without restarting OpenCode.
### Runtime hooks
Runtime hooks intercept live operations. Their event objects expose specific
mutable fields:
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("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
| `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 |
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
HTTP hooks can modify requests, inspect responses, retry, or return a
response without calling the provider. It applies to native models; AI SDK
models do not currently pass through this hook.
```ts
await ctx.session.hook("http", (event) => {
event.use((request, next) => {
request.headers.set("x-session-id", event.sessionID)
return next(request)
})
})
```
For example, remove a tool from selected model requests and normalize another
tool's input:
@@ -259,7 +272,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "acme.guards",
setup: async (ctx) => {
await ctx.session.hook("request", (event) => {
await ctx.session.hook("context", (event) => {
delete event.tools.write
})
+153 -6
View File
@@ -851,6 +851,16 @@
}
]
},
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"agent": {
"anyOf": [
{
@@ -11301,6 +11311,104 @@
}
}
},
"/api/vcs": {
"get": {
"tags": [
"vcs"
],
"operationId": "v2.vcs.get",
"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
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Info"
},
"data": {
"$ref": "#/components/schemas/Vcs.Info"
}
},
"required": [
"location",
"data"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Get current and default branch information for the requested location.",
"summary": "VCS info"
}
},
"/api/vcs/status": {
"get": {
"tags": [
@@ -12032,11 +12140,15 @@
},
"directory": {
"type": "string"
},
"canonical": {
"type": "string"
}
},
"required": [
"id",
"directory"
"directory",
"canonical"
],
"additionalProperties": false
}
@@ -12498,7 +12610,6 @@
"cost",
"tokens",
"time",
"title",
"location"
],
"additionalProperties": false
@@ -13837,6 +13948,15 @@
},
"message": {
"type": "string"
},
"status": {
"type": "integer",
"allOf": [
{
"minimum": 100,
"maximum": 599
}
]
}
},
"required": [
@@ -20765,7 +20885,7 @@
"id": {
"type": "string"
},
"worktree": {
"canonical": {
"type": "string"
},
"vcs": {
@@ -20792,7 +20912,7 @@
},
"required": [
"id",
"worktree",
"canonical",
"time",
"sandboxes"
],
@@ -20806,11 +20926,15 @@
},
"directory": {
"type": "string"
},
"canonical": {
"type": "string"
}
},
"required": [
"id",
"directory"
"directory",
"canonical"
],
"additionalProperties": false
},
@@ -22445,7 +22569,6 @@
"slug",
"projectID",
"directory",
"title",
"version",
"time"
],
@@ -29127,6 +29250,30 @@
],
"additionalProperties": false
},
"Vcs.Branch": {
"type": "object",
"properties": {
"current": {
"type": "string"
},
"default": {
"type": "string"
}
},
"additionalProperties": false
},
"Vcs.Info": {
"type": "object",
"properties": {
"branch": {
"$ref": "#/components/schemas/Vcs.Branch"
}
},
"required": [
"branch"
],
"additionalProperties": false
},
"Vcs.FileStatus": {
"type": "object",
"properties": {
+153 -6
View File
@@ -851,6 +851,16 @@
}
]
},
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"agent": {
"anyOf": [
{
@@ -11301,6 +11311,104 @@
}
}
},
"/api/vcs": {
"get": {
"tags": [
"vcs"
],
"operationId": "v2.vcs.get",
"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
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Info"
},
"data": {
"$ref": "#/components/schemas/Vcs.Info"
}
},
"required": [
"location",
"data"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Get current and default branch information for the requested location.",
"summary": "VCS info"
}
},
"/api/vcs/status": {
"get": {
"tags": [
@@ -12032,11 +12140,15 @@
},
"directory": {
"type": "string"
},
"canonical": {
"type": "string"
}
},
"required": [
"id",
"directory"
"directory",
"canonical"
],
"additionalProperties": false
}
@@ -12498,7 +12610,6 @@
"cost",
"tokens",
"time",
"title",
"location"
],
"additionalProperties": false
@@ -13837,6 +13948,15 @@
},
"message": {
"type": "string"
},
"status": {
"type": "integer",
"allOf": [
{
"minimum": 100,
"maximum": 599
}
]
}
},
"required": [
@@ -20765,7 +20885,7 @@
"id": {
"type": "string"
},
"worktree": {
"canonical": {
"type": "string"
},
"vcs": {
@@ -20792,7 +20912,7 @@
},
"required": [
"id",
"worktree",
"canonical",
"time",
"sandboxes"
],
@@ -20806,11 +20926,15 @@
},
"directory": {
"type": "string"
},
"canonical": {
"type": "string"
}
},
"required": [
"id",
"directory"
"directory",
"canonical"
],
"additionalProperties": false
},
@@ -22445,7 +22569,6 @@
"slug",
"projectID",
"directory",
"title",
"version",
"time"
],
@@ -29127,6 +29250,30 @@
],
"additionalProperties": false
},
"Vcs.Branch": {
"type": "object",
"properties": {
"current": {
"type": "string"
},
"default": {
"type": "string"
}
},
"additionalProperties": false
},
"Vcs.Info": {
"type": "object",
"properties": {
"branch": {
"$ref": "#/components/schemas/Vcs.Branch"
}
},
"required": [
"branch"
],
"additionalProperties": false
},
"Vcs.FileStatus": {
"type": "object",
"properties": {