mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 09:16:20 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d0b9a68f7 | |||
| fda2187a35 | |||
| 42e8d11552 | |||
| 28d784b83a | |||
| a4ad17347f | |||
| 93e8b75cca | |||
| 8df03aa1bc | |||
| 8ce850e142 | |||
| 3f30203b72 |
@@ -10,7 +10,7 @@
|
||||
|
||||
## Conventions
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
|
||||
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
|
||||
|
||||
+32
-1
@@ -3,8 +3,9 @@
|
||||
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode-ai/ai"
|
||||
import { RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
|
||||
@@ -20,6 +21,10 @@ const program = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
console.log(response.text)
|
||||
})
|
||||
|
||||
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
|
||||
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
|
||||
```
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
@@ -200,6 +205,32 @@ The hosted result is represented as a provider-executed tool call and tool resul
|
||||
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
|
||||
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||
|
||||
## Testing
|
||||
|
||||
Use the deterministic test client from `@opencode-ai/ai/testing` to script provider-neutral responses and inspect
|
||||
the requests sent by code under test:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
|
||||
const testLLM = TestLLM.layer({
|
||||
fallback: TestLLM.text("Hello from the test model", "text-1"),
|
||||
})
|
||||
|
||||
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
|
||||
const programWithTestClient = Effect.gen(function* () {
|
||||
const result = yield* program
|
||||
const test = yield* TestLLM.Service
|
||||
console.log(test.requests)
|
||||
return result
|
||||
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
|
||||
```
|
||||
|
||||
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
|
||||
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
|
||||
available on the yielded `TestLLM.Service`.
|
||||
|
||||
## Caching
|
||||
|
||||
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
|
||||
|
||||
@@ -15,11 +15,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
||||
|
||||
export const generate = <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
): Effect.Effect<ImageResponse, AIError> =>
|
||||
): Effect.Effect<ImageResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request)
|
||||
}) as Effect.Effect<ImageResponse, AIError>
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import { LLMClient } from "./route/client"
|
||||
import { LLMClient, Service } from "./route/client"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -151,10 +151,10 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
*/
|
||||
export function generateObject<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
|
||||
options: GenerateObjectOptions<S, SelectedLanguageModel>,
|
||||
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError>
|
||||
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError, Service>
|
||||
export function generateObject<const SelectedLanguageModel extends LanguageModel>(
|
||||
options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
|
||||
): Effect.Effect<GenerateObjectResponse<unknown>, AIError>
|
||||
): Effect.Effect<GenerateObjectResponse<unknown>, AIError, Service>
|
||||
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
|
||||
if ("schema" in options) {
|
||||
const { schema, ...rest } = options
|
||||
|
||||
@@ -422,18 +422,18 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
)
|
||||
})
|
||||
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError> {
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return (yield* Service).stream(request, options)
|
||||
}),
|
||||
) as Stream.Stream<LLMEvent, AIError>
|
||||
)
|
||||
}
|
||||
|
||||
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError> {
|
||||
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service> {
|
||||
return Effect.gen(function* () {
|
||||
return yield* (yield* Service).generate(request, options)
|
||||
}) as Effect.Effect<LLMResponse, AIError>
|
||||
})
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
Image,
|
||||
ImageClient,
|
||||
ImageInput,
|
||||
ImageModel,
|
||||
type ImageModelOptions,
|
||||
@@ -7,8 +9,13 @@ import {
|
||||
type ImageRequestFor,
|
||||
type ImageRoute,
|
||||
} from "../src"
|
||||
import type { Service } from "../src/image-client"
|
||||
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
|
||||
|
||||
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
|
||||
type Equal<A, B> = [A, B] extends [B, A] ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
type GoogleLikeOptions = {
|
||||
readonly aspectRatio?: "1:1" | "16:9"
|
||||
readonly imageSize?: "1K" | "2K"
|
||||
@@ -146,6 +153,9 @@ const request = Image.request({
|
||||
})
|
||||
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
|
||||
void typedRequest
|
||||
const generated = ImageClient.generate(request)
|
||||
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, Service>>
|
||||
void (true satisfies GenerateRequirements)
|
||||
|
||||
// @ts-expect-error Image requests no longer expose a common count option.
|
||||
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Schema } from "effect"
|
||||
import { LLM, type LanguageModel, type LanguageModelProviderOptions, type ProviderOptions } from "../src"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
LLM,
|
||||
type LLMClientService,
|
||||
type LanguageModel,
|
||||
type LanguageModelProviderOptions,
|
||||
type ProviderOptions,
|
||||
} from "../src"
|
||||
import { OpenAIChat } from "../src/protocols"
|
||||
|
||||
interface ExampleOptions {
|
||||
@@ -15,9 +21,19 @@ const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://example.com/v1" } })
|
||||
.model<ExampleProviderOptions>({ id: "example" })
|
||||
|
||||
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
|
||||
type StreamRequirements<T> = T extends Stream.Stream<infer _A, infer _E, infer R> ? R : never
|
||||
type Equal<A, B> = [A, B] extends [B, A] ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } })
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
|
||||
|
||||
const generated = LLM.generate(LLM.request({ model, prompt: "Hello" }))
|
||||
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, LLMClientService>>
|
||||
const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" }))
|
||||
type StreamClientRequirements = Assert<Equal<StreamRequirements<typeof streamed>, LLMClientService>>
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
@@ -25,12 +41,20 @@ LLM.request({
|
||||
providerOptions: { example: { mode: "slow" } },
|
||||
})
|
||||
|
||||
LLM.generateObject({
|
||||
const generatedObject = LLM.generateObject({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
schema: Schema.Struct({ answer: Schema.String }),
|
||||
providerOptions: { example: { mode: "thorough" } },
|
||||
})
|
||||
type GenerateObjectRequirements = Assert<Equal<Requirements<typeof generatedObject>, LLMClientService>>
|
||||
|
||||
const generatedDynamicObject = LLM.generateObject({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
jsonSchema: { type: "object" },
|
||||
})
|
||||
type GenerateDynamicObjectRequirements = Assert<Equal<Requirements<typeof generatedDynamicObject>, LLMClientService>>
|
||||
|
||||
LLM.generateObject({
|
||||
model,
|
||||
@@ -44,4 +68,8 @@ declare const generic: LanguageModel
|
||||
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
|
||||
|
||||
const options: LanguageModelProviderOptions<typeof model> = { example: { mode: "fast" } }
|
||||
void options
|
||||
void (options satisfies LanguageModelProviderOptions<typeof model>)
|
||||
void (true satisfies GenerateRequirements)
|
||||
void (true satisfies StreamClientRequirements)
|
||||
void (true satisfies GenerateObjectRequirements)
|
||||
void (true satisfies GenerateDynamicObjectRequirements)
|
||||
|
||||
@@ -1502,18 +1502,25 @@ export interface ProjectCopyApi<E = never> {
|
||||
export type Endpoint25_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||
|
||||
export type Endpoint25_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
|
||||
export type Endpoint25_2Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
readonly status: VcsStatusOperation<E>
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
@@ -210,6 +210,8 @@ import type {
|
||||
Endpoint25_0Output,
|
||||
Endpoint25_1Input,
|
||||
Endpoint25_1Output,
|
||||
Endpoint25_2Input,
|
||||
Endpoint25_2Output,
|
||||
Endpoint26_0Output,
|
||||
Endpoint26_1Input,
|
||||
Endpoint26_1Output,
|
||||
@@ -1182,17 +1184,26 @@ const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||
preserveEffect<Endpoint25_0Output>()(
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
|
||||
preserveEffect<Endpoint25_2Output>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint25_0(raw),
|
||||
status: Endpoint25_1(raw),
|
||||
diff: Endpoint25_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
@@ -202,6 +202,8 @@ import type {
|
||||
ProjectCopyRemoveOutput,
|
||||
ProjectCopyRefreshInput,
|
||||
ProjectCopyRefreshOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsDiffInput,
|
||||
@@ -1702,6 +1704,18 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
vcs: {
|
||||
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsStatusOutput>(
|
||||
{
|
||||
|
||||
@@ -530,6 +530,8 @@ export type ReferenceGitSource = {
|
||||
|
||||
export type ProjectCopyCopy = { directory: string }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
export type VcsFileStatus = {
|
||||
file: string
|
||||
additions: number
|
||||
@@ -1745,6 +1747,8 @@ export type SessionStatus2 = {
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type VcsInfo = { branch: VcsBranch }
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
|
||||
export type SessionInfo = {
|
||||
@@ -4902,6 +4906,17 @@ export type ProjectCopyRefreshInput = {
|
||||
|
||||
export type ProjectCopyRefreshOutput = void
|
||||
|
||||
export type VcsGetInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type VcsGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: VcsInfo
|
||||
}
|
||||
|
||||
export type VcsStatusInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -44,7 +44,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
export * as Agent from "./agent"
|
||||
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "./bus"
|
||||
import { State } from "./state"
|
||||
|
||||
const SHELL_OUTPUT_GLOB = (data: string) => path.join(data, "shell", "*", "*")
|
||||
const TOOL_OUTPUT_GLOB = (data: string) => path.join(data, "tool-output", "*")
|
||||
|
||||
export const ID = Agent.ID
|
||||
export type ID = typeof ID.Type
|
||||
export const Name = Agent.Name
|
||||
@@ -51,6 +56,13 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const global = yield* Global.Service
|
||||
const permissions: Info["permissions"] = [
|
||||
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB(global.data), effect: "allow" },
|
||||
{ action: "external_directory", resource: TOOL_OUTPUT_GLOB(global.data), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
|
||||
]
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "agent",
|
||||
initial: () => ({ agents: new Map() }),
|
||||
@@ -61,7 +73,13 @@ const layer = Layer.effect(
|
||||
draft.default = id
|
||||
},
|
||||
update: (id, fn) => {
|
||||
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
|
||||
const defaults = Info.default(id)
|
||||
const current =
|
||||
draft.agents.get(id) ??
|
||||
({
|
||||
...defaults,
|
||||
permissions: [...defaults.permissions, ...permissions],
|
||||
} as Types.DeepMutable<Info>)
|
||||
if (!draft.agents.has(id)) draft.agents.set(id, current)
|
||||
fn(current)
|
||||
current.id = id
|
||||
@@ -114,4 +132,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Global.node] })
|
||||
|
||||
@@ -58,7 +58,7 @@ export const Plugin = define({
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.map((content) => (content ? decode(file, content) : undefined)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
|
||||
@@ -71,6 +71,14 @@ const layer = Layer.effect(
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
if (location.vcs?.type === "hg") {
|
||||
const store = location.vcs.store
|
||||
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
|
||||
if (!config.includes(".hg") && !config.includes(vcs)) {
|
||||
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
}).pipe(
|
||||
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
|
||||
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as InstructionBuiltIns from "./builtins"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { Instructions } from "./index"
|
||||
@@ -15,6 +16,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
return Service.of({
|
||||
load: (sessionID) =>
|
||||
@@ -31,6 +33,7 @@ const layer = Layer.effect(
|
||||
` Workspace root folder: ${location.project.directory}`,
|
||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
"</env>",
|
||||
].join("\n"),
|
||||
),
|
||||
@@ -58,4 +61,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Global.node, Location.node] })
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
export * as AgentPlugin from "./agent"
|
||||
|
||||
import path from "path"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location"
|
||||
import { Permission } from "../permission"
|
||||
|
||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
||||
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
||||
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
||||
|
||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
Your strengths:
|
||||
@@ -100,38 +93,12 @@ Rules:
|
||||
export const Plugin = define({
|
||||
id: "opencode.agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
const worktree = location.directory
|
||||
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
|
||||
const readonlyExternalDirectory: Permission.Ruleset = [
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
...whitelistedDirs.map(
|
||||
(resource): Permission.Rule => ({ action: "external_directory", resource, effect: "allow" }),
|
||||
),
|
||||
]
|
||||
const defaults: Permission.Ruleset = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
...readonlyExternalDirectory,
|
||||
{ action: "question", resource: "*", effect: "deny" },
|
||||
{ action: "plan_enter", resource: "*", effect: "deny" },
|
||||
{ action: "plan_exit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
]
|
||||
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(Agent.defaultID, (item) => {
|
||||
item.name = Agent.Name.make("Build")
|
||||
item.description = "The default agent. Executes tools based on configured permissions."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
...Permission.merge(defaults, [
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "plan_enter", resource: "*", effect: "allow" },
|
||||
]),
|
||||
)
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("plan"), (item) => {
|
||||
@@ -139,18 +106,8 @@ export const Plugin = define({
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
...Permission.merge(defaults, [
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "plan_exit", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
|
||||
{
|
||||
action: "edit",
|
||||
resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
|
||||
effect: "allow",
|
||||
},
|
||||
]),
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -159,10 +116,16 @@ export const Plugin = define({
|
||||
item.description =
|
||||
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
|
||||
item.mode = "subagent"
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }]))
|
||||
item.permissions.push(
|
||||
{ action: "question", resource: "*", effect: "deny" },
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
)
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("explore"), (item) => {
|
||||
const externalDirectories = item.permissions.filter(
|
||||
(rule) => rule.action === "external_directory" && rule.effect === "allow",
|
||||
)
|
||||
item.name = Agent.Name.make("Explore")
|
||||
item.description =
|
||||
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
||||
@@ -170,7 +133,6 @@ export const Plugin = define({
|
||||
item.mode = "subagent"
|
||||
item.permissions.push(
|
||||
...Permission.merge(
|
||||
defaults,
|
||||
[
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
@@ -178,9 +140,12 @@ export const Plugin = define({
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
{ action: "websearch", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
],
|
||||
readonlyExternalDirectory,
|
||||
[{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories],
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -190,7 +155,7 @@ export const Plugin = define({
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_COMPACTION
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("title"), (item) => {
|
||||
@@ -198,7 +163,7 @@ export const Plugin = define({
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_TITLE
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("summary"), (item) => {
|
||||
@@ -206,7 +171,7 @@ export const Plugin = define({
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_SUMMARY
|
||||
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -189,43 +189,43 @@ export const layer = Layer.effect(
|
||||
.map(SystemPart.make)
|
||||
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
|
||||
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
|
||||
const toolDefinitions = tools.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
// Object identity preserves registry provenance when a hook moves a definition to a new key.
|
||||
const toolNamesByDefinition = new Map<object, string>()
|
||||
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
|
||||
const hookTools = Object.fromEntries(
|
||||
toolDefinitions.map((tool) => {
|
||||
const definition = { description: tool.description, input: { ...tool.inputSchema } }
|
||||
toolNamesByDefinition.set(definition, tool.name)
|
||||
return [tool.name, definition]
|
||||
}),
|
||||
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,
|
||||
),
|
||||
)
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const context = yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
system,
|
||||
messages,
|
||||
tools: hookTools,
|
||||
})
|
||||
const executableTools = new Map<string, string>()
|
||||
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
|
||||
const registeredName = toolNamesByDefinition.get(tool)
|
||||
const registered = registeredName ? toolsByName.get(registeredName) : undefined
|
||||
if (!registered || !registeredName) return []
|
||||
executableTools.set(name, registeredName)
|
||||
return [{ ...registered, name, description: tool.description, inputSchema: tool.input }]
|
||||
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
|
||||
})
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
// by key. Identity wins so a definition moved onto another tool's name still executes
|
||||
// the tool it describes. Entries matching neither were invented by a hook and dropped.
|
||||
// `tool.name` stays canonical so execution can translate renamed calls back.
|
||||
const hooked = new Map(
|
||||
Object.entries(context.tools).flatMap(([name, definition]) => {
|
||||
const tool = given.get(definition) ?? registry.get(name)
|
||||
if (!tool) return []
|
||||
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
|
||||
tools: hookedTools,
|
||||
system: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
@@ -265,17 +265,15 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
}
|
||||
const executeTool: Prepared["executeTool"] = (executeInput) => {
|
||||
const executeTool: Prepared["executeTool"] = (input) => {
|
||||
if (stepLimitReached)
|
||||
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
const registeredName = executableTools.get(executeInput.call.name)
|
||||
if (!registeredName && toolsByName.has(executeInput.call.name))
|
||||
return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` })
|
||||
const tool = hooked.get(input.call.name)
|
||||
// A registered tool absent from the hooked set was removed or renamed by a hook.
|
||||
if (!tool && registry.has(input.call.name))
|
||||
return new Tool.Error({ message: `Tool is not available for this request: ${input.call.name}` })
|
||||
return tools
|
||||
.execute({
|
||||
...executeInput,
|
||||
call: { ...executeInput.call, name: registeredName ?? executeInput.call.name },
|
||||
})
|
||||
.execute(tool ? { ...input, call: { ...input.call, name: tool.name } } : input)
|
||||
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
export * as Vcs from "./vcs"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Ref, Stream } from "effect"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "./location"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Bus } from "./bus"
|
||||
import { VcsGit } from "./vcs/git"
|
||||
import { VcsHg } from "./vcs/hg"
|
||||
|
||||
export { FileStatus, Mode }
|
||||
export { FileStatus, Info, Mode }
|
||||
|
||||
export interface DiffOptions {
|
||||
readonly context?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly info: () => Effect.Effect<Info>
|
||||
readonly status: () => Effect.Effect<FileStatus[]>
|
||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
|
||||
}
|
||||
@@ -38,8 +43,48 @@ const layer = Layer.effect(
|
||||
const proc = yield* AppProcess.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const bus = yield* Bus.Service
|
||||
const impl = adapter(proc, fs, location)
|
||||
const vcs = location.vcs
|
||||
const cache = vcs && impl ? yield* Ref.make(yield* impl.info()) : undefined
|
||||
|
||||
if (cache && vcs && impl) {
|
||||
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
vcs.type === "git"
|
||||
? path.basename(event.data.file) === "HEAD" && FSUtil.contains(store, event.data.file)
|
||||
: path.resolve(event.data.file) === path.join(store, "branch"),
|
||||
),
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
const previous = yield* Ref.get(cache)
|
||||
const next = yield* impl.info()
|
||||
yield* Ref.set(cache, next)
|
||||
if (previous.branch.current === next.branch.current) return
|
||||
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
|
||||
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
|
||||
const info = Effect.fnUntraced(function* () {
|
||||
if (!impl) return { branch: {} }
|
||||
if (!cache) return yield* impl.info()
|
||||
const current = yield* Ref.get(cache)
|
||||
if (current.branch.current !== undefined && current.branch.default !== undefined) return current
|
||||
// An unborn repository can gain its first branch without changing existing metadata.
|
||||
const next = yield* impl.info()
|
||||
yield* Ref.set(cache, next)
|
||||
return next
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
info: Effect.fn("Vcs.info")(function* () {
|
||||
return yield* info()
|
||||
}),
|
||||
status: Effect.fn("Vcs.status")(function* () {
|
||||
if (!impl) return []
|
||||
return yield* impl.status()
|
||||
@@ -55,5 +100,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node],
|
||||
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as VcsGit from "./git"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import type { DiffOptions, Interface } from "../vcs"
|
||||
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch"
|
||||
@@ -20,6 +20,12 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor
|
||||
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
|
||||
|
||||
return {
|
||||
info: Effect.fn("VcsGit.info")(function* () {
|
||||
const [current, root] = yield* Effect.all([ctx.git.branch(ctx.directory), ctx.git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
return { branch: { current, default: root?.name } } satisfies Info
|
||||
}),
|
||||
status: Effect.fn("VcsGit.status")(function* () {
|
||||
const git = ctx.git
|
||||
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileStatus, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import type { DiffOptions, Interface } from "../vcs"
|
||||
@@ -73,6 +73,9 @@ export function make(
|
||||
})
|
||||
|
||||
return {
|
||||
info: Effect.fn("VcsHg.info")(function* () {
|
||||
return { branch: { current: yield* hg.branch(), default: "default" } } satisfies Info
|
||||
}),
|
||||
status: Effect.fn("VcsHg.status")(function* () {
|
||||
const [items, batch] = yield* Effect.all(
|
||||
// Zero-context patches are enough to count changed lines.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
@@ -9,15 +10,19 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { agentHost, host } from "./plugin/host"
|
||||
|
||||
const testLocation = location({ directory: AbsolutePath.make("/project") })
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
|
||||
const global = Global.make({ data: "/data", config: "/config", tmp: "/tmp/opencode" })
|
||||
const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
|
||||
[Global.node, globalLayer],
|
||||
[Location.node, locationLayer],
|
||||
]) as unknown as Layer.Layer<unknown, never>,
|
||||
)
|
||||
@@ -120,25 +125,35 @@ describe("Agent", () => {
|
||||
const id = Agent.ID.make("custom")
|
||||
|
||||
yield* agent.transform((editor) => editor.update(id, () => {}))
|
||||
expect(yield* agent.get(id)).toEqual(Agent.Info.empty(id))
|
||||
const info = yield* agent.get(id)
|
||||
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
|
||||
Agent.Info.default(id).permissions,
|
||||
)
|
||||
expect(Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? []).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? []).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect).toBe(
|
||||
"allow",
|
||||
)
|
||||
|
||||
yield* agent.transform((editor) => editor.remove(id))
|
||||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not ambiently opt built-in agents into bash", () =>
|
||||
it.effect("applies managed external directories without opting built-in agents into bash", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
yield* AgentPlugin.Plugin.effect(
|
||||
host({
|
||||
agent: agentHost(agent),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
),
|
||||
)
|
||||
|
||||
const agents = yield* agent.list()
|
||||
@@ -152,6 +167,20 @@ describe("Agent", () => {
|
||||
"title",
|
||||
])
|
||||
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
|
||||
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
|
||||
expect(
|
||||
Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), permissions).effect,
|
||||
).toBe("allow")
|
||||
expect(
|
||||
Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), permissions).effect,
|
||||
).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
|
||||
const explore = yield* agent.get(Agent.ID.make("explore"))
|
||||
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
|
||||
expect(Permission.evaluate("read", "src/index.ts", explore?.permissions ?? []).effect).toBe("allow")
|
||||
for (const item of agents) {
|
||||
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
|
||||
}
|
||||
@@ -165,11 +194,6 @@ describe("Agent", () => {
|
||||
host({
|
||||
agent: agentHost(agent),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.forEach(["general", "explore"], (id) =>
|
||||
|
||||
@@ -20,10 +20,13 @@ import { agentHost, host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const defaultPermissions = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
] satisfies Permission.Ruleset
|
||||
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
|
||||
...Agent.Info.default(Agent.ID.make("test")).permissions,
|
||||
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
|
||||
]
|
||||
|
||||
test("rejects named agent color tokens", () => {
|
||||
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
|
||||
@@ -58,6 +61,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("applies all global permissions before agent-specific permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const global = yield* Global.Service
|
||||
const build = Agent.ID.make("build")
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(build, (agent) => {
|
||||
@@ -110,7 +114,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
const buildAgent = yield* agents.get(build)
|
||||
if (!buildAgent) throw new Error("expected configured build agent")
|
||||
expect(buildAgent.permissions).toEqual([
|
||||
...defaultPermissions,
|
||||
...defaultPermissions(global),
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
@@ -128,7 +132,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
||||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
...defaultPermissions,
|
||||
...defaultPermissions(global),
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
@@ -136,7 +140,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
])
|
||||
expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
||||
expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
|
||||
...defaultPermissions,
|
||||
...defaultPermissions(global),
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "allow" },
|
||||
@@ -266,9 +270,11 @@ permissions:
|
||||
Use native v2 fields.`,
|
||||
)
|
||||
await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
|
||||
await fs.writeFile(path.join(tmp.path, "agents", "empty.md"), "")
|
||||
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",
|
||||
@@ -286,15 +292,16 @@ Use native v2 fields.`,
|
||||
system: "Review carefully.",
|
||||
description: "Markdown description",
|
||||
request: { body: { temperature: 0.5 } },
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
||||
expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
|
||||
system: "Use native v2 fields.",
|
||||
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -153,12 +153,16 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||
|
||||
function withTmp<A, E, R>(
|
||||
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
|
||||
options?: { git?: boolean; init?: (directory: string) => Promise<void> },
|
||||
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const tmp = await tmpdir()
|
||||
if (!options?.git) return { tmp, vcs: undefined }
|
||||
if (options?.vcs === "hg") {
|
||||
await fs.mkdir(path.join(tmp.path, ".hg"))
|
||||
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
|
||||
}
|
||||
if (options?.vcs !== "git") return { tmp, vcs: undefined }
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
|
||||
@@ -292,7 +296,7 @@ describeWatcher("LocationWatcher", () => {
|
||||
})
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -322,7 +326,7 @@ describeWatcher("LocationWatcher", () => {
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -359,7 +363,7 @@ describeWatcher("LocationWatcher", () => {
|
||||
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -376,7 +380,7 @@ describeWatcher("LocationWatcher", () => {
|
||||
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||
).toMatchObject({ file: head })
|
||||
}),
|
||||
{ git: true },
|
||||
{ vcs: "git" },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -401,7 +405,7 @@ describeWatcher("LocationWatcher", () => {
|
||||
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
vcs: "git",
|
||||
init: async (directory) => {
|
||||
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||
await fs.rename(path.join(directory, ".git"), actual)
|
||||
@@ -411,4 +415,19 @@ describeWatcher("LocationWatcher", () => {
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("publishes .hg/branch events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const branch = path.join(directory, ".hg", "branch")
|
||||
yield* ready(directory)
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||
).toMatchObject({ file: branch })
|
||||
}),
|
||||
{ vcs: "hg" },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,8 +5,8 @@ import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("global paths", () => {
|
||||
test("tmp path is under the system temp directory", () => {
|
||||
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
|
||||
test("tmp path is the canonical system temp directory", async () => {
|
||||
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
|
||||
expect(Global.make().tmp).toBe(Global.Path.tmp)
|
||||
})
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const locationLayer = Layer.succeed(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(InstructionBuiltIns.node, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: "/global" })],
|
||||
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ describe("InstructionBuiltIns", () => {
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { readInitial, readUpdate } from "./lib/instructions"
|
||||
const build = Agent.ID.make("build")
|
||||
|
||||
const selection = (permissions: Permission.Ruleset = []) => {
|
||||
const info = Agent.Info.make({ ...Agent.Info.empty(build), permissions })
|
||||
const info = Agent.Info.make({ ...Agent.Info.default(build), permissions })
|
||||
return { id: info.id, info }
|
||||
}
|
||||
|
||||
|
||||
@@ -887,15 +887,13 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises and executes a tool renamed by a session context hook", () =>
|
||||
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(() => {
|
||||
const tool = event.tools.echo
|
||||
if (!tool) return
|
||||
event.tools.renamed_echo = tool
|
||||
event.tools.renamed_echo = event.tools.echo!
|
||||
delete event.tools.echo
|
||||
}),
|
||||
)
|
||||
@@ -907,19 +905,6 @@ describe("SessionRunnerLLM", () => {
|
||||
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"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Use the renamed tool" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-renamed",
|
||||
state: { status: "completed", content: [{ type: "text", text: "renamed" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
]
|
||||
for (const [core, shared] of schemas) expect(core).toBe(shared)
|
||||
|
||||
expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(Agent.Info.empty(Agent.ID.make("test")))
|
||||
expect(Agent.Info.default(Agent.ID.make("test"))).toEqual(Agent.Info.default(Agent.ID.make("test")))
|
||||
expect(coreModel.Info.default(coreProvider.ID.make("test"), coreModel.ID.make("model"))).toEqual(
|
||||
Model.Info.default(Provider.ID.make("test"), Model.ID.make("model")),
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ const layer = (list: () => Skill.Info[]) =>
|
||||
describe("SkillInstructions", () => {
|
||||
it.effect("renders described agent skills and updates the complete available list", () => {
|
||||
const agent = Agent.Info.make({
|
||||
...Agent.Info.empty(build),
|
||||
...Agent.Info.default(build),
|
||||
permissions: [{ action: "skill", resource: "denied", effect: "deny" }],
|
||||
})
|
||||
let skills = [hidden, denied, manual, effect]
|
||||
@@ -80,7 +80,7 @@ describe("SkillInstructions", () => {
|
||||
})
|
||||
|
||||
it.effect("announces added and removed skills as deltas without restating the list", () => {
|
||||
const agent = Agent.Info.make(Agent.Info.empty(build))
|
||||
const agent = Agent.Info.make(Agent.Info.default(build))
|
||||
const debugging = Skill.Info.make({
|
||||
id: Skill.ID.make("debugging"),
|
||||
name: Skill.Name.make("Debugging"),
|
||||
@@ -117,7 +117,7 @@ describe("SkillInstructions", () => {
|
||||
})
|
||||
|
||||
it.effect("restates the full skill list when a description changes", () => {
|
||||
const agent = Agent.Info.make(Agent.Info.empty(build))
|
||||
const agent = Agent.Info.make(Agent.Info.default(build))
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
@@ -138,7 +138,7 @@ describe("SkillInstructions", () => {
|
||||
|
||||
it.effect("omits instructions when the selected agent denies all skills", () => {
|
||||
const agent = Agent.Info.make({
|
||||
...Agent.Info.empty(build),
|
||||
...Agent.Info.default(build),
|
||||
permissions: [{ action: "skill", resource: "*", effect: "deny" }],
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
@@ -149,7 +149,7 @@ describe("SkillInstructions", () => {
|
||||
|
||||
it.effect("omits instructions when a resource-specific denial follows the global denial", () => {
|
||||
const agent = Agent.Info.make({
|
||||
...Agent.Info.empty(build),
|
||||
...Agent.Info.default(build),
|
||||
permissions: [
|
||||
{ action: "skill", resource: "*", effect: "deny" },
|
||||
{ action: "skill", resource: "hidden", effect: "deny" },
|
||||
@@ -163,7 +163,7 @@ describe("SkillInstructions", () => {
|
||||
|
||||
it.effect("retains specifically allowed skills after a global denial", () => {
|
||||
const agent = Agent.Info.make({
|
||||
...Agent.Info.empty(build),
|
||||
...Agent.Info.default(build),
|
||||
permissions: [
|
||||
{ action: "skill", resource: "*", effect: "deny" },
|
||||
{ action: "skill", resource: "effect", effect: "allow" },
|
||||
@@ -179,7 +179,7 @@ describe("SkillInstructions", () => {
|
||||
|
||||
it.effect("omits instructions when a specifically allowed skill is denied again", () => {
|
||||
const agent = Agent.Info.make({
|
||||
...Agent.Info.empty(build),
|
||||
...Agent.Info.default(build),
|
||||
permissions: [
|
||||
{ action: "skill", resource: "*", effect: "deny" },
|
||||
{ action: "skill", resource: "effect", effect: "allow" },
|
||||
|
||||
@@ -301,6 +301,7 @@ describe("ShellTool", () => {
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("rejects a workdir that stops being a directory during approval", () =>
|
||||
@@ -472,6 +473,7 @@ describe("ShellTool", () => {
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
@@ -538,7 +540,7 @@ describe("ShellTool", () => {
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
@@ -557,6 +559,7 @@ describe("ShellTool", () => {
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("returns the shell id for a background command", () =>
|
||||
|
||||
@@ -2,11 +2,14 @@ import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
@@ -15,7 +18,7 @@ const describeHg = Bun.which("hg") ? describe : describe.skip
|
||||
|
||||
const provide = (directory: string) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(Vcs.node, [
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
@@ -100,6 +103,39 @@ describeHg("Vcs mercurial", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("caches branch info and publishes branch metadata changes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await hg(directory, "init")
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
|
||||
const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, {
|
||||
file: path.join(directory, ".hg", "branch"),
|
||||
event: "change",
|
||||
})
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("respects the context option", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -160,6 +196,7 @@ 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" },
|
||||
])
|
||||
|
||||
@@ -2,18 +2,21 @@ import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const provide = (directory: string, input: { git?: boolean } = {}) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(Vcs.node, [
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
@@ -35,6 +38,13 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
|
||||
const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
withTmp((directory) =>
|
||||
Effect.promise(() => initRepo(directory)).pipe(
|
||||
Effect.andThen(f(directory).pipe(provide(directory, { git: true }))),
|
||||
),
|
||||
)
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init -b main`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
@@ -53,6 +63,7 @@ describe("Vcs", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
@@ -61,10 +72,9 @@ describe("Vcs", () => {
|
||||
)
|
||||
|
||||
it.live("reports modified, deleted, and untracked files", () =>
|
||||
withTmp((directory) =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
|
||||
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
|
||||
await commitAll(directory, "initial")
|
||||
@@ -79,15 +89,45 @@ describe("Vcs", () => {
|
||||
{ file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
|
||||
{ file: "new.txt", additions: 2, deletions: 0, status: "added" },
|
||||
])
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("caches branch info and publishes HEAD changes", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: "main" } })
|
||||
|
||||
const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: "main" } })
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
expect(yield* Fiber.join(updated)).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { location: { directory }, data: { branch: "feature" } },
|
||||
})
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diffs the working copy against HEAD with patches", () =>
|
||||
withTmp((directory) =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
|
||||
await commitAll(directory, "initial")
|
||||
await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n")
|
||||
@@ -105,16 +145,15 @@ describe("Vcs", () => {
|
||||
expect(diff[0].deletions).toBe(1)
|
||||
expect(diff[1].patch).toContain("+hello")
|
||||
expect(diff[1].additions).toBe(1)
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("respects the context option", () =>
|
||||
withTmp((directory) =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "file.txt"), body)
|
||||
await commitAll(directory, "initial")
|
||||
await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed"))
|
||||
@@ -126,15 +165,14 @@ describe("Vcs", () => {
|
||||
const tight = yield* vcs.diff("working", { context: 1 })
|
||||
expect(tight[0].patch).toContain("line-9")
|
||||
expect(tight[0].patch).not.toContain("line-0")
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diffs before the first commit", () =>
|
||||
withTmp((directory) =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "new.txt"), "hello\n")
|
||||
})
|
||||
const vcs = yield* Vcs.Service
|
||||
@@ -142,15 +180,14 @@ describe("Vcs", () => {
|
||||
const diff = yield* vcs.diff("working")
|
||||
expect(diff).toHaveLength(1)
|
||||
expect(diff[0].patch).toContain("+hello")
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diffs a feature branch against the default branch", () =>
|
||||
withTmp((directory) =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(directory)
|
||||
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
|
||||
await commitAll(directory, "initial")
|
||||
})
|
||||
@@ -167,7 +204,7 @@ describe("Vcs", () => {
|
||||
{ file: "file.txt", status: "modified" },
|
||||
])
|
||||
expect(diff[0].patch).toContain("+two")
|
||||
}).pipe(provide(directory, { git: true })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -851,6 +851,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11301,6 +11311,104 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"vcs"
|
||||
],
|
||||
"operationId": "v2.vcs.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Info"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Vcs.Info"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"location",
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Get current and default branch information for the requested location.",
|
||||
"summary": "VCS info"
|
||||
}
|
||||
},
|
||||
"/api/vcs/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -12032,11 +12140,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -12498,7 +12610,6 @@
|
||||
"cost",
|
||||
"tokens",
|
||||
"time",
|
||||
"title",
|
||||
"location"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@@ -13837,6 +13948,15 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20765,7 +20885,7 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"worktree": {
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
"vcs": {
|
||||
@@ -20792,7 +20912,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"worktree",
|
||||
"canonical",
|
||||
"time",
|
||||
"sandboxes"
|
||||
],
|
||||
@@ -20806,11 +20926,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -22445,7 +22569,6 @@
|
||||
"slug",
|
||||
"projectID",
|
||||
"directory",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
],
|
||||
@@ -29127,6 +29250,30 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Branch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"$ref": "#/components/schemas/Vcs.Branch"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"branch"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.FileStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -13,6 +13,20 @@ const DiffQuery = Schema.Struct({
|
||||
})
|
||||
|
||||
export const VcsGroup = HttpApiGroup.make("server.vcs")
|
||||
.add(
|
||||
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Vcs.Info),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.vcs.get",
|
||||
summary: "VCS info",
|
||||
description: "Get current and default branch information for the requested location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("vcs.status", "/api/vcs/status", {
|
||||
query: LocationQuery,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const Info = Schema.Struct({
|
||||
.annotate({ identifier: "Agent.Info" })
|
||||
.pipe(
|
||||
statics(() => ({
|
||||
empty: (id: ID) =>
|
||||
default: (id: ID) =>
|
||||
({
|
||||
id,
|
||||
name: Name.make(id),
|
||||
@@ -46,6 +46,9 @@ export const Info = Schema.Struct({
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
],
|
||||
}) satisfies Info,
|
||||
})),
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
export * as Vcs from "./vcs.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "./schema.js"
|
||||
import { NonNegativeInt, optional } from "./schema.js"
|
||||
|
||||
export const Branch = Schema.Struct({
|
||||
current: optional(Schema.String),
|
||||
default: optional(Schema.String),
|
||||
}).annotate({ identifier: "Vcs.Branch" })
|
||||
export interface Branch extends Schema.Schema.Type<typeof Branch> {}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
branch: Branch,
|
||||
}).annotate({ identifier: "Vcs.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" })
|
||||
export type Mode = typeof Mode.Type
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FileDiff } from "../src/file-diff.js"
|
||||
import { Money } from "../src/money.js"
|
||||
import { Skill } from "../src/skill.js"
|
||||
import { Shell } from "../src/shell.js"
|
||||
import { Vcs } from "../src/vcs.js"
|
||||
import { PersistedRevert } from "../src/session-revert.js"
|
||||
import { AbsolutePath, optional } from "../src/schema.js"
|
||||
|
||||
@@ -134,6 +135,10 @@ describe("contract hygiene", () => {
|
||||
expect(Pty.ID.create()).toStartWith("pty_")
|
||||
})
|
||||
|
||||
test("VCS info omits unavailable branch names", () => {
|
||||
expect(Schema.encodeSync(Vcs.Info)({ branch: { current: undefined, default: undefined } })).toEqual({ branch: {} })
|
||||
})
|
||||
|
||||
test("reusable public identifiers are stable and unique", () => {
|
||||
const identifiers = [
|
||||
Agent.Color,
|
||||
@@ -166,6 +171,8 @@ describe("contract hygiene", () => {
|
||||
SessionPending.SyntheticData,
|
||||
SessionPending.User,
|
||||
SessionPending.Synthetic,
|
||||
Vcs.Branch,
|
||||
Vcs.Info,
|
||||
].map((schema) => schema.ast.annotations?.identifier)
|
||||
|
||||
expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true)
|
||||
|
||||
@@ -7,6 +7,14 @@ import { response } from "../location"
|
||||
export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers
|
||||
.handle("vcs.get", () =>
|
||||
response(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.info()
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handle("vcs.status", () =>
|
||||
response(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -125,7 +125,6 @@ type ToolName =
|
||||
| "webfetch"
|
||||
| "websearch"
|
||||
| "skill"
|
||||
| "plan_exit"
|
||||
|
||||
type ToolRule = {
|
||||
view: ToolView
|
||||
@@ -516,15 +515,6 @@ function runLsp(p: ToolProps): ToolInline {
|
||||
}
|
||||
}
|
||||
|
||||
function runPlanExit(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "→",
|
||||
title: "Switching to build agent",
|
||||
mode: "block",
|
||||
body: p.frame.status === "completed" ? p.frame.output : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function patchTitle(file: PatchFile, directory?: string): string {
|
||||
if (file.status === "added") {
|
||||
return `# Created ${toolPath(file.file, { directory })}`
|
||||
@@ -1077,16 +1067,6 @@ const TOOL_RULES = {
|
||||
start: scrollSkillStart,
|
||||
},
|
||||
},
|
||||
plan_exit: {
|
||||
view: {
|
||||
output: true,
|
||||
final: false,
|
||||
},
|
||||
run: runPlanExit,
|
||||
scroll: {
|
||||
start: () => "",
|
||||
},
|
||||
},
|
||||
} as const satisfies ToolRegistry
|
||||
|
||||
function key(name: string): name is ToolName {
|
||||
|
||||
@@ -13,6 +13,8 @@ const config = path.join(xdgConfig!, app)
|
||||
const state = path.join(xdgState!, app)
|
||||
const tmp = path.join(os.tmpdir(), app)
|
||||
|
||||
await fs.mkdir(tmp, { recursive: true })
|
||||
|
||||
const paths = {
|
||||
get home() {
|
||||
return process.env.OPENCODE_TEST_HOME ?? os.homedir()
|
||||
@@ -24,7 +26,7 @@ const paths = {
|
||||
cache,
|
||||
config,
|
||||
state,
|
||||
tmp,
|
||||
tmp: await fs.realpath(tmp),
|
||||
}
|
||||
|
||||
export const Path = paths
|
||||
@@ -35,7 +37,6 @@ await Promise.all([
|
||||
fs.mkdir(Path.data, { recursive: true }),
|
||||
fs.mkdir(Path.config, { recursive: true }),
|
||||
fs.mkdir(Path.state, { recursive: true }),
|
||||
fs.mkdir(Path.tmp, { recursive: true }),
|
||||
fs.mkdir(Path.log, { recursive: true }),
|
||||
fs.mkdir(Path.bin, { recursive: true }),
|
||||
fs.mkdir(Path.repos, { recursive: true }),
|
||||
|
||||
@@ -81,8 +81,7 @@ current built-in actions use these resources:
|
||||
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
|
||||
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
|
||||
|
||||
Built-in agent policy also reserves `plan_enter` and `plan_exit` for plan-mode
|
||||
transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.
|
||||
`doom_loop` and `lsp` are not current V2 Core permission actions.
|
||||
|
||||
## External directories
|
||||
|
||||
@@ -132,7 +131,9 @@ matching, so authorize only trusted directory boundaries.
|
||||
|
||||
## Defaults
|
||||
|
||||
The evaluator's fallback is `ask`, but shipped agents include ordered defaults:
|
||||
Every agent, including custom agents, starts with ordered defaults that allow
|
||||
tools, ask for external directories, ask for `.env` reads, and allow
|
||||
`.env.example` reads. Shipped agents then add their own policies:
|
||||
|
||||
| Agent | Effective default policy |
|
||||
| --- | --- |
|
||||
@@ -153,8 +154,12 @@ The base read rules are ordered as follows:
|
||||
]
|
||||
```
|
||||
|
||||
OpenCode also permits its managed tool-output and temporary directories where
|
||||
needed. These exceptions do not grant general external-directory access.
|
||||
OpenCode also permits its managed tool-output, shell-output, temporary, and
|
||||
global configuration directories. These exceptions apply only to the
|
||||
external-directory boundary for every agent; the underlying action still uses
|
||||
its own permission rules. The environment instructions identify the temporary
|
||||
directory available for work outside the workspace. Later global and
|
||||
agent-specific rules can override these defaults.
|
||||
|
||||
## Agent overrides
|
||||
|
||||
|
||||
+153
-6
@@ -851,6 +851,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11301,6 +11311,104 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"vcs"
|
||||
],
|
||||
"operationId": "v2.vcs.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Info"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Vcs.Info"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"location",
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Get current and default branch information for the requested location.",
|
||||
"summary": "VCS info"
|
||||
}
|
||||
},
|
||||
"/api/vcs/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -12032,11 +12140,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -12498,7 +12610,6 @@
|
||||
"cost",
|
||||
"tokens",
|
||||
"time",
|
||||
"title",
|
||||
"location"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@@ -13837,6 +13948,15 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20765,7 +20885,7 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"worktree": {
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
"vcs": {
|
||||
@@ -20792,7 +20912,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"worktree",
|
||||
"canonical",
|
||||
"time",
|
||||
"sandboxes"
|
||||
],
|
||||
@@ -20806,11 +20926,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -22445,7 +22569,6 @@
|
||||
"slug",
|
||||
"projectID",
|
||||
"directory",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
],
|
||||
@@ -29127,6 +29250,30 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Branch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"$ref": "#/components/schemas/Vcs.Branch"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"branch"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.FileStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -851,6 +851,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11301,6 +11311,104 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"vcs"
|
||||
],
|
||||
"operationId": "v2.vcs.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Info"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Vcs.Info"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"location",
|
||||
"data"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Get current and default branch information for the requested location.",
|
||||
"summary": "VCS info"
|
||||
}
|
||||
},
|
||||
"/api/vcs/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -12032,11 +12140,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -12498,7 +12610,6 @@
|
||||
"cost",
|
||||
"tokens",
|
||||
"time",
|
||||
"title",
|
||||
"location"
|
||||
],
|
||||
"additionalProperties": false
|
||||
@@ -13837,6 +13948,15 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20765,7 +20885,7 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"worktree": {
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
"vcs": {
|
||||
@@ -20792,7 +20912,7 @@
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"worktree",
|
||||
"canonical",
|
||||
"time",
|
||||
"sandboxes"
|
||||
],
|
||||
@@ -20806,11 +20926,15 @@
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"directory"
|
||||
"directory",
|
||||
"canonical"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -22445,7 +22569,6 @@
|
||||
"slug",
|
||||
"projectID",
|
||||
"directory",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
],
|
||||
@@ -29127,6 +29250,30 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Branch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"$ref": "#/components/schemas/Vcs.Branch"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"branch"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Vcs.FileStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user