mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 17:26:22 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5aeea550c | |||
| 930b1dde3c |
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||
import { RequestExecutor } from "./executor"
|
||||
import { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
||||
import type { HttpMiddleware, HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
@@ -156,6 +156,7 @@ export interface Interface {
|
||||
|
||||
export interface StreamOptions {
|
||||
readonly transform?: HttpRequestTransform
|
||||
readonly http?: HttpMiddleware
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
@@ -308,6 +309,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
transform: options?.transform,
|
||||
middleware: options?.http,
|
||||
}),
|
||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||
const route = `${request.model.provider}/${request.model.route.id}`
|
||||
|
||||
@@ -20,9 +20,18 @@ import { classifyProviderFailure } from "../provider-error"
|
||||
export interface Interface {
|
||||
readonly execute: (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
middleware?: HttpMiddleware,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
|
||||
}
|
||||
|
||||
export type HttpHandler = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||
export type HttpMiddleware = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
handler: HttpHandler,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
||||
|
||||
const BODY_LIMIT = 16_384
|
||||
@@ -282,12 +291,20 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||
Effect.gen(function* () {
|
||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
return yield* http
|
||||
.execute(request)
|
||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||
if (!middleware)
|
||||
return yield* http
|
||||
.execute(request)
|
||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||
|
||||
const response = yield* middleware(request, (input) =>
|
||||
http
|
||||
.execute(input)
|
||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
).pipe(Effect.mapError(toHttpError(redactedNames)))
|
||||
return yield* statusError(response.request, redactedNames)(response)
|
||||
})
|
||||
return Service.of({
|
||||
execute: executeOnce,
|
||||
|
||||
@@ -23,4 +23,11 @@ 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 { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
export type {
|
||||
HttpHandler,
|
||||
HttpMiddleware,
|
||||
HttpRequest,
|
||||
HttpRequestTransform,
|
||||
Transport as TransportDef,
|
||||
TransportRuntime,
|
||||
} from "./transport"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Auth } from "../auth"
|
||||
import { render as renderEndpoint } from "../endpoint"
|
||||
import { Framing } from "../framing"
|
||||
import type { Transport, TransportPrepareInput } from "./index"
|
||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
||||
import * as ProviderShared from "../../protocols/shared"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
|
||||
export interface HttpPrepared<Frame> {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly framing: Framing.Definition<Frame>
|
||||
readonly middleware?: HttpMiddleware
|
||||
}
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
@@ -74,21 +75,23 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
prepare: (prepareInput) =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* jsonRequestParts({ ...prepareInput })
|
||||
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
|
||||
yield* (prepareInput.transform?.(request) ?? Effect.void)
|
||||
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),
|
||||
})
|
||||
return {
|
||||
request: ProviderShared.jsonPost({
|
||||
url: request.url,
|
||||
body: request.body ?? "",
|
||||
headers: Headers.fromInput(request.headers),
|
||||
}),
|
||||
request,
|
||||
framing: input.framing,
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
frames: (prepared, request, runtime) =>
|
||||
Stream.unwrap(
|
||||
runtime.http
|
||||
.execute(prepared.request)
|
||||
.execute(prepared.request, prepared.middleware)
|
||||
.pipe(
|
||||
Effect.map((response) =>
|
||||
prepared.framing.frame(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Effect, Stream } from "effect"
|
||||
import { Endpoint } from "../endpoint"
|
||||
import { Auth } from "../auth"
|
||||
import type { Interface as RequestExecutorInterface } from "../executor"
|
||||
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
|
||||
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
||||
import type { AIError, LLMRequest } from "../../schema"
|
||||
|
||||
@@ -33,7 +33,9 @@ export interface TransportPrepareInput<Body> {
|
||||
readonly encodeBody: (body: Body) => string
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly transform?: HttpRequestTransform
|
||||
readonly middleware?: HttpMiddleware
|
||||
}
|
||||
|
||||
export * as HttpTransport from "./http"
|
||||
export type { HttpHandler, HttpMiddleware } from "../executor"
|
||||
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, mergeProviderOptions } from "../src"
|
||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
||||
import { Auth, LLMClient } from "../src/route"
|
||||
@@ -146,12 +146,16 @@ describe("request option precedence", () => {
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
transform: (request) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.headers.authorization).toBe("Bearer fresh-key")
|
||||
request.url = "https://proxy.test/v1/chat/completions"
|
||||
request.headers["x-plugin"] = "transformed"
|
||||
request.body = JSON.stringify({ transformed: true })
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* handler(
|
||||
request.pipe(
|
||||
HttpClientRequest.setUrl("https://proxy.test/v1/chat/completions"),
|
||||
HttpClientRequest.setMethod("PUT"),
|
||||
HttpClientRequest.setHeader("x-plugin", "transformed"),
|
||||
HttpClientRequest.bodyText(JSON.stringify({ transformed: true }), "application/custom+json"),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
@@ -160,7 +164,9 @@ describe("request option precedence", () => {
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
|
||||
expect(web.method).toBe("PUT")
|
||||
expect(web.headers.get("x-plugin")).toBe("transformed")
|
||||
expect(web.headers.get("content-type")).toBe("application/custom+json")
|
||||
expect(decodeJson(input.text)).toEqual({ transformed: true })
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
@@ -171,6 +177,82 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("transforms the HTTP response before protocol decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* handler(request)
|
||||
return HttpClientResponse.fromWeb(
|
||||
response.request,
|
||||
new Response((yield* response.text).replace("network", "hooked"), {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("hooked")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("can inspect an error response and retry the native request", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* handler(request)
|
||||
expect(response.status).toBe(401)
|
||||
return yield* handler(HttpClientRequest.setHeader(request, "authorization", "Bearer refreshed"))
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(attempts, (value) => value + 1)
|
||||
if (input.request.headers.authorization !== "Bearer refreshed")
|
||||
return input.respond("unauthorized", { status: 401 })
|
||||
return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("retried")
|
||||
expect(yield* Ref.get(attempts)).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
|
||||
+38
-205
@@ -1,4 +1,4 @@
|
||||
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
@@ -37,34 +37,6 @@ export type TurnStart =
|
||||
| { readonly type: "skill"; readonly id: string }
|
||||
| { readonly type: "compaction"; readonly id: string }
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
|
||||
type ChildSessionUpdateBase = {
|
||||
readonly rootSessionId: string
|
||||
readonly childSessionId: string
|
||||
readonly parentSessionId: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
export type ChildSessionUpdate = ChildSessionUpdateBase &
|
||||
(
|
||||
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||
| {
|
||||
readonly type: "status"
|
||||
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||
readonly error?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
)
|
||||
|
||||
type ChildSession = {
|
||||
readonly id: string
|
||||
readonly parentID: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
function emptyToolState(): ToolState {
|
||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||
}
|
||||
@@ -78,13 +50,8 @@ export async function streamTurn(input: {
|
||||
readonly writeTextFile: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
readonly connectionSignal?: AbortSignal
|
||||
readonly sessionSignal?: AbortSignal
|
||||
}): Promise<PromptResponse> {
|
||||
const streamController = new AbortController()
|
||||
const connectionAbort = () => streamController.abort()
|
||||
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
|
||||
const connected = await stream.next()
|
||||
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
||||
@@ -95,113 +62,47 @@ export async function streamTurn(input: {
|
||||
let finish: SessionMessageAssistant["finish"]
|
||||
let executionError: { readonly type: string; readonly message: string } | undefined
|
||||
const tools = new Map<string, ToolState>()
|
||||
const children = new Map<string, ChildSession>()
|
||||
const openChildren = new Set<string>()
|
||||
let standard = true
|
||||
let handedOff = false
|
||||
|
||||
const notifyChild = async (
|
||||
child: ChildSession,
|
||||
value:
|
||||
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||
| {
|
||||
readonly type: "status"
|
||||
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||
readonly error?: { readonly type: string; readonly message: string }
|
||||
},
|
||||
) => {
|
||||
if (!input.childSessionUpdate) return
|
||||
await input
|
||||
.childSessionUpdate({
|
||||
rootSessionId: input.sessionID,
|
||||
childSessionId: child.id,
|
||||
parentSessionId: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...value,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
|
||||
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
|
||||
|
||||
const updateSession = async (value: SessionUpdate, child?: ChildSession) => {
|
||||
const projected = child ? projectChildUpdate(value, child) : value
|
||||
if (standard && (!child || !input.childSessionUpdate)) {
|
||||
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
|
||||
}
|
||||
if (child) await notifyChild(child, { type: "update", update: projected })
|
||||
}
|
||||
|
||||
const consume = async (mode: "turn" | "background") => {
|
||||
const consume = async () => {
|
||||
while (!streamController.signal.aborted) {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||
const event = next.value
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.info.parentID
|
||||
if (!parentID) continue
|
||||
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
|
||||
if ((mode === "turn" && parentID === input.sessionID) || parent) {
|
||||
const child = {
|
||||
id: event.data.sessionID,
|
||||
parentID,
|
||||
depth: parent ? parent.depth + 1 : 1,
|
||||
title: event.data.info.title,
|
||||
}
|
||||
children.set(child.id, child)
|
||||
openChildren.add(child.id)
|
||||
await notifyChild(child, { type: "status", status: "created" })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const eventSessionID = sessionID(event)
|
||||
const child = eventSessionID ? children.get(eventSessionID) : undefined
|
||||
const send = (update: SessionUpdate) => updateSession(update, child)
|
||||
if (mode === "background" && !child) continue
|
||||
|
||||
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
|
||||
const tool = event.data.source?.callID
|
||||
? tools.get(toolKey(event.data.sessionID, event.data.source.callID))
|
||||
: undefined
|
||||
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
|
||||
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
|
||||
await replyPermission({
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event,
|
||||
sessionID: event.data.sessionID,
|
||||
clientSessionID: input.sessionID,
|
||||
sessionID: input.sessionID,
|
||||
cwd: input.cwd,
|
||||
tool,
|
||||
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
|
||||
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
|
||||
await input.client.form
|
||||
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
|
||||
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
|
||||
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
|
||||
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
|
||||
continue
|
||||
}
|
||||
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
|
||||
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
||||
if (matchesStart(event, input.start)) {
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (!started) continue
|
||||
|
||||
if (event.type === "session.execution.started") {
|
||||
if (child) {
|
||||
await notifyChild(child, { type: "status", status: "running" })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
await send({
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
await update({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
@@ -209,8 +110,8 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
await send({
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
await update({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
@@ -218,14 +119,9 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(toolKey(event.data.sessionID, event.data.callID), {
|
||||
name: event.data.name,
|
||||
input: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
})
|
||||
await send({
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
||||
await update({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: event.data.callID,
|
||||
@@ -237,12 +133,11 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
const key = toolKey(event.data.sessionID, event.data.callID)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
const current = tools.get(event.data.callID) ?? emptyToolState()
|
||||
current.input = event.data.input
|
||||
tools.set(key, current)
|
||||
await send({
|
||||
tools.set(event.data.callID, current)
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.callID,
|
||||
@@ -254,10 +149,10 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(toolKey(event.data.sessionID, event.data.callID))
|
||||
const current = tools.get(event.data.callID)
|
||||
if (!current) continue
|
||||
current.metadata = event.data.metadata
|
||||
await send({
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.callID,
|
||||
@@ -269,9 +164,8 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const key = toolKey(event.data.sessionID, event.data.callID)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
const current = tools.get(event.data.callID) ?? emptyToolState()
|
||||
tools.delete(event.data.callID)
|
||||
await syncEditedFiles({
|
||||
connection: input.connection,
|
||||
writeTextFile: input.writeTextFile,
|
||||
@@ -281,7 +175,7 @@ export async function streamTurn(input: {
|
||||
toolInput: current.input,
|
||||
metadata: event.data.metadata ?? {},
|
||||
}).catch(() => {})
|
||||
await send({
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: event.data.callID,
|
||||
@@ -294,10 +188,9 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
const key = toolKey(event.data.sessionID, event.data.callID)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
await send({
|
||||
const current = tools.get(event.data.callID) ?? emptyToolState()
|
||||
tools.delete(event.data.callID)
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: event.data.callID,
|
||||
@@ -312,33 +205,13 @@ export async function streamTurn(input: {
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.step.ended") {
|
||||
if (!child) {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
finish = event.data.finish
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.succeeded") {
|
||||
if (!child) return "succeeded" as const
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "completed" })
|
||||
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
if (!child) return "interrupted" as const
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "interrupted" })
|
||||
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
finish = event.data.finish
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.succeeded") return "succeeded" as const
|
||||
if (event.type === "session.execution.interrupted") return "interrupted" as const
|
||||
if (event.type === "session.execution.failed") {
|
||||
if (child) {
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
|
||||
if (mode === "background" && openChildren.size === 0) return "failed" as const
|
||||
continue
|
||||
}
|
||||
executionError = event.data.error
|
||||
return "failed" as const
|
||||
}
|
||||
@@ -346,13 +219,7 @@ export async function streamTurn(input: {
|
||||
return "interrupted" as const
|
||||
}
|
||||
|
||||
const completed = consume("turn")
|
||||
const closeStream = async () => {
|
||||
streamController.abort()
|
||||
input.connectionSignal?.removeEventListener("abort", connectionAbort)
|
||||
input.sessionSignal?.removeEventListener("abort", connectionAbort)
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
const completed = consume()
|
||||
try {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
if (!control.cancelled) throw error
|
||||
@@ -366,14 +233,6 @@ export async function streamTurn(input: {
|
||||
}
|
||||
}
|
||||
const terminal = await completed
|
||||
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
|
||||
standard = false
|
||||
handedOff = true
|
||||
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
void consume("background")
|
||||
.catch(() => {})
|
||||
.finally(closeStream)
|
||||
}
|
||||
const assistant = assistantMessageID
|
||||
? await input.client.session
|
||||
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
|
||||
@@ -391,37 +250,11 @@ export async function streamTurn(input: {
|
||||
await completed.catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
if (!handedOff) await closeStream()
|
||||
streamController.abort()
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function sessionID(event: EventSubscribeOutput) {
|
||||
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||
if (event.type === "form.created") return event.data.form.sessionID
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolKey(sessionID: string, callID: string) {
|
||||
return `${sessionID}:${callID}`
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
update._meta = {
|
||||
...update._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
|
||||
update.toolCallId = `${child.id}:${update.toolCallId}`
|
||||
if (update.title && child.title) update.title = `${child.title}: ${update.title}`
|
||||
}
|
||||
return update
|
||||
}
|
||||
|
||||
export async function replayMessages(
|
||||
connection: Pick<AgentSideConnection, "sessionUpdate">,
|
||||
sessionID: string,
|
||||
|
||||
@@ -20,30 +20,20 @@ export async function replyPermission(input: {
|
||||
readonly connection: Connection
|
||||
readonly event: PermissionEvent
|
||||
readonly sessionID: string
|
||||
readonly clientSessionID?: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
}) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const result = await input.connection
|
||||
.requestPermission({
|
||||
sessionId: input.clientSessionID ?? input.sessionID,
|
||||
sessionId: input.sessionID,
|
||||
toolCall: {
|
||||
...pendingToolCall({
|
||||
toolCallId: [input.toolCallPrefix, input.event.data.source?.callID ?? input.event.data.id]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(":"),
|
||||
toolCallId: input.event.data.source?.callID ?? input.event.data.id,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: [input.titlePrefix, permissionTitle(toolName, toolInput, previews)]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(": "),
|
||||
},
|
||||
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||
|
||||
@@ -43,21 +43,13 @@ import { OPENCODE_VERSION } from "../version"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
||||
import { promptContentToParts } from "./content"
|
||||
import {
|
||||
ChildSessionUpdateMethod,
|
||||
ChildSessionUpdatesCapability,
|
||||
replayMessages,
|
||||
streamTurn,
|
||||
type ChildSessionUpdate,
|
||||
type TurnControl,
|
||||
type TurnStart,
|
||||
} from "./event"
|
||||
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
||||
Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
|
||||
Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
|
||||
type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
@@ -72,7 +64,6 @@ type Catalog = {
|
||||
type Attached = {
|
||||
readonly id: string
|
||||
readonly cwd: string
|
||||
readonly abort: AbortController
|
||||
catalog: Catalog
|
||||
model: ModelRef
|
||||
modeID: string
|
||||
@@ -109,7 +100,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
const catalogs = new Map<string, Promise<Catalog>>()
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, TurnControl>()
|
||||
const capabilities = { writeTextFile: false, childSessionUpdates: false }
|
||||
const capabilities = { writeTextFile: false }
|
||||
|
||||
const catalog = (cwd: string) => {
|
||||
const cached = catalogs.get(cwd)
|
||||
@@ -130,11 +121,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
|
||||
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
|
||||
const currentCatalog = await catalog(cwd)
|
||||
sessions.get(session.id)?.abort.abort()
|
||||
const state: Attached = {
|
||||
id: session.id,
|
||||
cwd,
|
||||
abort: new AbortController(),
|
||||
catalog: currentCatalog,
|
||||
model: session.model ?? currentCatalog.defaultModel,
|
||||
modeID: session.agent ?? currentCatalog.defaultModeID,
|
||||
@@ -172,7 +161,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
return {
|
||||
initialize: async (params) => {
|
||||
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
||||
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -190,7 +178,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { [ChildSessionUpdatesCapability]: true },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||
@@ -237,7 +224,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
})
|
||||
sessions.get(params.sessionId)?.abort.abort()
|
||||
sessions.delete(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
return {}
|
||||
@@ -248,7 +234,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
return { configOptions: configOptions(state) }
|
||||
},
|
||||
closeSession: async (params) => {
|
||||
sessions.get(params.sessionId)?.abort.abort()
|
||||
sessions.delete(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
const turn = active.get(params.sessionId)
|
||||
@@ -311,7 +296,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const extNotification = input.connection.extNotification
|
||||
active.set(state.id, control)
|
||||
const response = await streamTurn({
|
||||
client: input.client,
|
||||
@@ -321,15 +305,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
start: prepared.start,
|
||||
writeTextFile: capabilities.writeTextFile,
|
||||
control,
|
||||
connectionSignal: input.connection.signal,
|
||||
sessionSignal: state.abort.signal,
|
||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||
...(capabilities.childSessionUpdates && extNotification
|
||||
? {
|
||||
childSessionUpdate: (update: ChildSessionUpdate) =>
|
||||
extNotification(ChildSessionUpdateMethod, update).then(() => {}),
|
||||
}
|
||||
: {}),
|
||||
}).finally(() => {
|
||||
if (active.get(state.id) === control) active.delete(state.id)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { resolve } from "node:path"
|
||||
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
|
||||
import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event"
|
||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
@@ -191,181 +191,6 @@ describe("acp event behavior", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("projects foreground child session updates onto the parent turn", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_child",
|
||||
info: childSession("ses_child", "ses_parent", "Explore code"),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||
send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
callID: "call_read",
|
||||
name: "read",
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
callID: "call_read",
|
||||
input: { path: "/workspace/src/index.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child",
|
||||
callID: "call_read",
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: "source" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_parent",
|
||||
inputID: "input_parent",
|
||||
})
|
||||
|
||||
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
|
||||
["ses_parent", "tool_call"],
|
||||
["ses_parent", "tool_call_update"],
|
||||
["ses_parent", "tool_call_update"],
|
||||
])
|
||||
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
|
||||
"ses_child:call_read",
|
||||
"ses_child:call_read",
|
||||
"ses_child:call_read",
|
||||
])
|
||||
expect(updates[0]?.update).toMatchObject({
|
||||
title: "Explore code: read",
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_child",
|
||||
parentID: "ses_parent",
|
||||
depth: 1,
|
||||
title: "Explore code",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("continues child extension updates after the parent turn ends", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const childUpdates: ChildSessionUpdate[] = []
|
||||
const completed = Promise.withResolvers<void>()
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_background",
|
||||
info: childSession("ses_background", "ses_parent", "Background research"),
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await turn({
|
||||
fixture,
|
||||
connection: recordingConnection(updates),
|
||||
sessionID: "ses_parent",
|
||||
inputID: "input_parent",
|
||||
childSessionUpdate: async (update) => {
|
||||
childUpdates.push(update)
|
||||
if (update.type === "status" && update.status === "completed") completed.resolve()
|
||||
},
|
||||
})
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
|
||||
fixture.send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_future",
|
||||
info: childSession("ses_future", "ses_parent", "Later turn child"),
|
||||
}),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
|
||||
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
|
||||
fixture.send(
|
||||
durableEvent("session.tool.input.started", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
callID: "call_shell",
|
||||
name: "shell",
|
||||
}),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.called", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
callID: "call_shell",
|
||||
input: { command: "pwd" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
fixture.send(
|
||||
durableEvent("session.tool.success", {
|
||||
sessionID: "ses_background",
|
||||
assistantMessageID: "msg_background",
|
||||
callID: "call_shell",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: "/workspace" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
|
||||
await withTimeout(completed.promise, "background child completion was not delivered")
|
||||
|
||||
expect(updates).toEqual([])
|
||||
expect(
|
||||
childUpdates.map((update) =>
|
||||
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
|
||||
),
|
||||
).toEqual([
|
||||
["status", "created"],
|
||||
["status", "running"],
|
||||
["update", "tool_call"],
|
||||
["update", "tool_call_update"],
|
||||
["update", "tool_call_update"],
|
||||
["status", "completed"],
|
||||
])
|
||||
expect(childUpdates[2]).toMatchObject({
|
||||
rootSessionId: "ses_parent",
|
||||
childSessionId: "ses_background",
|
||||
parentSessionId: "ses_parent",
|
||||
depth: 1,
|
||||
title: "Background research",
|
||||
type: "update",
|
||||
update: { toolCallId: "ses_background:call_shell" },
|
||||
})
|
||||
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("streams tool pending, progress, success, and failure updates", async () => {
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const fixture = createSseFixture({
|
||||
@@ -731,7 +556,6 @@ function turn(input: {
|
||||
readonly connection: Connection
|
||||
readonly sessionID: string
|
||||
readonly inputID: string
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
}) {
|
||||
return streamTurn({
|
||||
client: input.fixture.client,
|
||||
@@ -741,25 +565,11 @@ function turn(input: {
|
||||
start: { type: "input", id: input.inputID },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
childSessionUpdate: input.childSessionUpdate,
|
||||
submit: (signal) =>
|
||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
||||
})
|
||||
}
|
||||
|
||||
function childSession(id: string, parentID: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: "project",
|
||||
directory: "/workspace",
|
||||
parentID,
|
||||
title,
|
||||
version: "test",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function tokens() {
|
||||
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
}
|
||||
|
||||
@@ -153,68 +153,6 @@ describe("acp permission behavior", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||
send(
|
||||
durableEvent("session.created", {
|
||||
sessionID: "ses_child",
|
||||
info: {
|
||||
id: "ses_child",
|
||||
slug: "ses_child",
|
||||
projectID: "project",
|
||||
directory: "/workspace",
|
||||
parentID: "ses_parent",
|
||||
title: "Review code",
|
||||
version: "test",
|
||||
time: { created: 1, updated: 1 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||
send(
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/child.ts" },
|
||||
source: { type: "tool", messageID: "msg_child", callID: "call_child" },
|
||||
}),
|
||||
)
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||
},
|
||||
})
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push(request)
|
||||
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||
},
|
||||
} satisfies Connection
|
||||
|
||||
try {
|
||||
await startTurn(fixture, connection, "ses_parent", "input_parent")
|
||||
|
||||
expect(permissionRequests).toHaveLength(1)
|
||||
expect(permissionRequests[0]).toMatchObject({
|
||||
sessionId: "ses_parent",
|
||||
toolCall: {
|
||||
toolCallId: "ses_child:call_child",
|
||||
title: "Review code: /workspace/child.ts",
|
||||
},
|
||||
})
|
||||
expect(fixture.requests).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/session/ses_child/permission/perm_child/reply",
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("previews edits during approval and syncs the completed file", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
||||
const file = path.join(cwd, "file.ts")
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { ACPService } from "../../src/acp/service"
|
||||
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
|
||||
|
||||
describe("acp service", () => {
|
||||
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
||||
@@ -40,17 +39,11 @@ describe("acp service", () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const initialized = await service.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
|
||||
clientInfo: { name: "test", version: "1" },
|
||||
})
|
||||
const result = await service.newSession({
|
||||
cwd: "/workspace",
|
||||
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
|
||||
})
|
||||
expect(result.sessionId).toBe("ses_acp")
|
||||
expect(initialized.agentCapabilities?._meta).toEqual({ [ChildSessionUpdatesCapability]: true })
|
||||
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||
expect(requests).toContainEqual({
|
||||
method: "PUT",
|
||||
|
||||
@@ -1502,25 +1502,18 @@ 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: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||
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_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_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
|
||||
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 interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
readonly status: VcsStatusOperation<E>
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
@@ -210,8 +210,6 @@ import type {
|
||||
Endpoint25_0Output,
|
||||
Endpoint25_1Input,
|
||||
Endpoint25_1Output,
|
||||
Endpoint25_2Input,
|
||||
Endpoint25_2Output,
|
||||
Endpoint26_0Output,
|
||||
Endpoint26_1Input,
|
||||
Endpoint26_1Output,
|
||||
@@ -1184,26 +1182,17 @@ 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_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
|
||||
preserveEffect<Endpoint25_2Output>()(
|
||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint25_0(raw),
|
||||
status: Endpoint25_1(raw),
|
||||
diff: Endpoint25_2(raw),
|
||||
})
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
|
||||
|
||||
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
@@ -202,8 +202,6 @@ import type {
|
||||
ProjectCopyRemoveOutput,
|
||||
ProjectCopyRefreshInput,
|
||||
ProjectCopyRefreshOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsDiffInput,
|
||||
@@ -1704,18 +1702,6 @@ 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,8 +530,6 @@ export type ReferenceGitSource = {
|
||||
|
||||
export type ProjectCopyCopy = { directory: string }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
export type VcsFileStatus = {
|
||||
file: string
|
||||
additions: number
|
||||
@@ -1747,8 +1745,6 @@ export type SessionStatus2 = {
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type VcsInfo = { branch: VcsBranch }
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
|
||||
export type SessionInfo = {
|
||||
@@ -4906,17 +4902,6 @@ 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
|
||||
|
||||
@@ -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(["get", "status", "diff"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["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"])
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
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
|
||||
@@ -56,13 +51,6 @@ 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() }),
|
||||
@@ -73,13 +61,7 @@ const layer = Layer.effect(
|
||||
draft.default = id
|
||||
},
|
||||
update: (id, fn) => {
|
||||
const defaults = Info.default(id)
|
||||
const current =
|
||||
draft.agents.get(id) ??
|
||||
({
|
||||
...defaults,
|
||||
permissions: [...defaults.permissions, ...permissions],
|
||||
} as Types.DeepMutable<Info>)
|
||||
const current = draft.agents.get(id) ?? (Info.default(id) as Types.DeepMutable<Info>)
|
||||
if (!draft.agents.has(id)) draft.agents.set(id, current)
|
||||
fn(current)
|
||||
current.id = id
|
||||
@@ -132,4 +114,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Global.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -16,7 +15,6 @@ 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) =>
|
||||
@@ -33,7 +31,6 @@ 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"),
|
||||
),
|
||||
@@ -61,4 +58,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Global.node, Location.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
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:
|
||||
@@ -93,12 +100,38 @@ 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({ action: "question", resource: "*", effect: "allow" })
|
||||
item.permissions.push(
|
||||
...Permission.merge(defaults, [
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "plan_enter", resource: "*", effect: "allow" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("plan"), (item) => {
|
||||
@@ -106,8 +139,18 @@ export const Plugin = define({
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
...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",
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -116,16 +159,10 @@ 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(
|
||||
{ action: "question", resource: "*", effect: "deny" },
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
)
|
||||
item.permissions.push(...Permission.merge(defaults, [{ 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.'
|
||||
@@ -133,6 +170,7 @@ export const Plugin = define({
|
||||
item.mode = "subagent"
|
||||
item.permissions.push(
|
||||
...Permission.merge(
|
||||
defaults,
|
||||
[
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
@@ -140,12 +178,9 @@ 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" },
|
||||
],
|
||||
[{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories],
|
||||
readonlyExternalDirectory,
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -155,7 +190,7 @@ export const Plugin = define({
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_COMPACTION
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("title"), (item) => {
|
||||
@@ -163,7 +198,7 @@ export const Plugin = define({
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_TITLE
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("summary"), (item) => {
|
||||
@@ -171,7 +206,7 @@ export const Plugin = define({
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_SUMMARY
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -189,43 +189,34 @@ 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 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", {
|
||||
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", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
system,
|
||||
messages,
|
||||
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
|
||||
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 }]
|
||||
: []
|
||||
})
|
||||
// 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: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
system: contextEvent.system,
|
||||
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
@@ -265,15 +256,13 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
}
|
||||
const executeTool: Prepared["executeTool"] = (input) => {
|
||||
const executeTool: Prepared["executeTool"] = (executeInput) => {
|
||||
if (stepLimitReached)
|
||||
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
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}` })
|
||||
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}` })
|
||||
return tools
|
||||
.execute(tool ? { ...input, call: { ...input.call, name: tool.name } } : input)
|
||||
.execute(executeInput)
|
||||
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Vcs from "./vcs"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "./location"
|
||||
@@ -10,14 +10,13 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { VcsGit } from "./vcs/git"
|
||||
import { VcsHg } from "./vcs/hg"
|
||||
|
||||
export { FileStatus, Info, Mode }
|
||||
export { FileStatus, 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[]>
|
||||
}
|
||||
@@ -41,10 +40,6 @@ const layer = Layer.effect(
|
||||
const location = yield* Location.Service
|
||||
const impl = adapter(proc, fs, location)
|
||||
return Service.of({
|
||||
info: Effect.fn("Vcs.info")(function* () {
|
||||
if (!impl) return { branch: {} }
|
||||
return yield* impl.info()
|
||||
}),
|
||||
status: Effect.fn("Vcs.status")(function* () {
|
||||
if (!impl) return []
|
||||
return yield* impl.status()
|
||||
|
||||
@@ -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, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FileStatus, 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,12 +20,6 @@ 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,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, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FileStatus, 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,9 +73,6 @@ 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.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
@@ -10,19 +9,15 @@ 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>,
|
||||
)
|
||||
@@ -125,35 +120,25 @@ describe("Agent", () => {
|
||||
const id = Agent.ID.make("custom")
|
||||
|
||||
yield* agent.transform((editor) => editor.update(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",
|
||||
)
|
||||
expect(yield* agent.get(id)).toEqual(Agent.Info.default(id))
|
||||
|
||||
yield* agent.transform((editor) => editor.remove(id))
|
||||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies managed external directories without opting built-in agents into bash", () =>
|
||||
it.effect("does not ambiently opt 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()
|
||||
@@ -167,20 +152,6 @@ 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)
|
||||
}
|
||||
@@ -194,6 +165,11 @@ 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) =>
|
||||
|
||||
@@ -20,13 +20,10 @@ 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 = (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" },
|
||||
]
|
||||
const defaultPermissions = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
] satisfies Permission.Ruleset
|
||||
|
||||
test("rejects named agent color tokens", () => {
|
||||
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
|
||||
@@ -61,7 +58,6 @@ 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) => {
|
||||
@@ -114,7 +110,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
const buildAgent = yield* agents.get(build)
|
||||
if (!buildAgent) throw new Error("expected configured build agent")
|
||||
expect(buildAgent.permissions).toEqual([
|
||||
...defaultPermissions(global),
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
@@ -132,7 +128,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
||||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
...defaultPermissions(global),
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
@@ -140,7 +136,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(global),
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "allow" },
|
||||
@@ -274,7 +270,6 @@ 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",
|
||||
@@ -292,13 +287,13 @@ Use native v2 fields.`,
|
||||
system: "Review carefully.",
|
||||
description: "Markdown description",
|
||||
request: { body: { temperature: 0.5 } },
|
||||
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [...defaultPermissions, { 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(global), { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
|
||||
|
||||
@@ -5,8 +5,8 @@ import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("global paths", () => {
|
||||
test("tmp path is the canonical system temp directory", async () => {
|
||||
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
|
||||
test("tmp path is under the system temp directory", () => {
|
||||
expect(Global.Path.tmp).toBe(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", tmp: "/temporary" })],
|
||||
[Global.node, Global.layerWith({ config: "/global" })],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -49,7 +49,6 @@ 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)}`,
|
||||
|
||||
@@ -887,27 +887,6 @@ 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
|
||||
|
||||
@@ -160,7 +160,6 @@ describeHg("Vcs mercurial", () => {
|
||||
await commitAll(directory, "feature change")
|
||||
})
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "file.txt", status: "modified" },
|
||||
])
|
||||
|
||||
@@ -53,7 +53,6 @@ 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([])
|
||||
@@ -164,7 +163,6 @@ describe("Vcs", () => {
|
||||
await commitAll(directory, "feature change")
|
||||
})
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
|
||||
{ file: "file.txt", status: "modified" },
|
||||
])
|
||||
|
||||
@@ -851,16 +851,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11311,104 +11301,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -12140,15 +12032,11 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory",
|
||||
"canonical"
|
||||
"directory"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -12610,6 +12498,7 @@
|
||||
"cost",
|
||||
"tokens",
|
||||
"time",
|
||||
"title",
|
||||
"location"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@@ -13948,15 +13837,6 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20885,7 +20765,7 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"worktree": {
|
||||
"type": "string"
|
||||
},
|
||||
"vcs": {
|
||||
@@ -20912,7 +20792,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"canonical",
|
||||
"worktree",
|
||||
"time",
|
||||
"sandboxes"
|
||||
],
|
||||
@@ -20926,15 +20806,11 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory",
|
||||
"canonical"
|
||||
"directory"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -22569,6 +22445,7 @@
|
||||
"slug",
|
||||
"projectID",
|
||||
"directory",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
],
|
||||
@@ -29250,30 +29127,6 @@
|
||||
],
|
||||
"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": {
|
||||
|
||||
@@ -13,20 +13,6 @@ 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,
|
||||
|
||||
@@ -46,9 +46,6 @@ 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,
|
||||
})),
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
export * as Vcs from "./vcs.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
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> {}
|
||||
import { NonNegativeInt } from "./schema.js"
|
||||
|
||||
export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" })
|
||||
export type Mode = typeof Mode.Type
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
|
||||
@@ -135,10 +134,6 @@ 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,
|
||||
@@ -171,8 +166,6 @@ 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)
|
||||
|
||||
@@ -7,14 +7,6 @@ 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* () {
|
||||
|
||||
@@ -125,6 +125,7 @@ type ToolName =
|
||||
| "webfetch"
|
||||
| "websearch"
|
||||
| "skill"
|
||||
| "plan_exit"
|
||||
|
||||
type ToolRule = {
|
||||
view: ToolView
|
||||
@@ -515,6 +516,15 @@ 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 })}`
|
||||
@@ -1067,6 +1077,16 @@ 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 {
|
||||
|
||||
@@ -13,8 +13,6 @@ 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()
|
||||
@@ -26,7 +24,7 @@ const paths = {
|
||||
cache,
|
||||
config,
|
||||
state,
|
||||
tmp: await fs.realpath(tmp),
|
||||
tmp,
|
||||
}
|
||||
|
||||
export const Path = paths
|
||||
@@ -37,6 +35,7 @@ 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,7 +81,8 @@ 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 |
|
||||
|
||||
`doom_loop` and `lsp` are not current V2 Core permission actions.
|
||||
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.
|
||||
|
||||
## External directories
|
||||
|
||||
@@ -131,9 +132,7 @@ matching, so authorize only trusted directory boundaries.
|
||||
|
||||
## 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:
|
||||
The evaluator's fallback is `ask`, but shipped agents include ordered defaults:
|
||||
|
||||
| Agent | Effective default policy |
|
||||
| --- | --- |
|
||||
@@ -154,12 +153,8 @@ The base read rules are ordered as follows:
|
||||
]
|
||||
```
|
||||
|
||||
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.
|
||||
OpenCode also permits its managed tool-output and temporary directories where
|
||||
needed. These exceptions do not grant general external-directory access.
|
||||
|
||||
## Agent overrides
|
||||
|
||||
|
||||
+6
-153
@@ -851,16 +851,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11311,104 +11301,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -12140,15 +12032,11 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory",
|
||||
"canonical"
|
||||
"directory"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -12610,6 +12498,7 @@
|
||||
"cost",
|
||||
"tokens",
|
||||
"time",
|
||||
"title",
|
||||
"location"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@@ -13948,15 +13837,6 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20885,7 +20765,7 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"worktree": {
|
||||
"type": "string"
|
||||
},
|
||||
"vcs": {
|
||||
@@ -20912,7 +20792,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"canonical",
|
||||
"worktree",
|
||||
"time",
|
||||
"sandboxes"
|
||||
],
|
||||
@@ -20926,15 +20806,11 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory",
|
||||
"canonical"
|
||||
"directory"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -22569,6 +22445,7 @@
|
||||
"slug",
|
||||
"projectID",
|
||||
"directory",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
],
|
||||
@@ -29250,30 +29127,6 @@
|
||||
],
|
||||
"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": {
|
||||
|
||||
@@ -851,16 +851,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11311,104 +11301,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -12140,15 +12032,11 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory",
|
||||
"canonical"
|
||||
"directory"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -12610,6 +12498,7 @@
|
||||
"cost",
|
||||
"tokens",
|
||||
"time",
|
||||
"title",
|
||||
"location"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@@ -13948,15 +13837,6 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20885,7 +20765,7 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"worktree": {
|
||||
"type": "string"
|
||||
},
|
||||
"vcs": {
|
||||
@@ -20912,7 +20792,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"canonical",
|
||||
"worktree",
|
||||
"time",
|
||||
"sandboxes"
|
||||
],
|
||||
@@ -20926,15 +20806,11 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory",
|
||||
"canonical"
|
||||
"directory"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -22569,6 +22445,7 @@
|
||||
"slug",
|
||||
"projectID",
|
||||
"directory",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
],
|
||||
@@ -29250,30 +29127,6 @@
|
||||
],
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user